App Report via Export Jobs, Umlaut-Suche, Geräte-Export, Mitglieder entfernen
- App Report: Zahlen per AppInstallStatusAggregate Export Job (ZIP/CSV) - Geräte-Export: getDeviceInstallStatusReport statt deviceStatuses - Benutzersuche: ConsistencyLevel+$count für Umlaut-Kompatibilität - Gruppen: Mitglieder entfernen in modalMembers und Group Explorer (gex) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+133
-62
@@ -1571,9 +1571,8 @@ function Get-AppCategoriesEndpoint {
|
||||
}
|
||||
|
||||
function Get-AppInstallReportEndpoint {
|
||||
# Faellt zurueck auf deviceStatuses pro App (via $batch), da installSummary
|
||||
# und getAppInstallSummaryReport DeviceManagementManagedDevices.Read.All
|
||||
# benoetigen (fehlt in dieser App-Registration -> 400).
|
||||
# Verwendet den Intune Export-Job (AppInstallStatusAggregate).
|
||||
# Benoetigt: DeviceManagementApps.Read.All
|
||||
$err = Test-Connected
|
||||
if ($err) { return $err }
|
||||
|
||||
@@ -1581,50 +1580,88 @@ function Get-AppInstallReportEndpoint {
|
||||
$rawApps = @(Get-GraphMobileApps)
|
||||
if ($rawApps.Count -eq 0) { return @{ items = @(); count = 0 } }
|
||||
|
||||
Write-Host "[REPORT] Zaehle Installationsstatus fuer $($rawApps.Count) Apps via deviceStatuses..." -ForegroundColor DarkCyan
|
||||
Write-Host "[REPORT] Starte Export-Job (AppInstallStatusAggregate)..." -ForegroundColor DarkCyan
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
$batchSize = 20
|
||||
# appId -> hashtable mit Zaehler
|
||||
$counts = @{}
|
||||
foreach ($a in $rawApps) { $counts[[string]$a.id] = @{ ok=0; fail=0; pending=0; notInst=0; notAppl=0 } }
|
||||
$summaries = @{} # appId -> Zaehler
|
||||
|
||||
$firstBatch = $true
|
||||
for ($i = 0; $i -lt $rawApps.Count; $i += $batchSize) {
|
||||
$chunk = $rawApps[$i .. [Math]::Min($i + $batchSize - 1, $rawApps.Count - 1)]
|
||||
$requests = @($chunk | ForEach-Object {
|
||||
$aid = [string]$_.id
|
||||
@{ id = $aid; method = 'GET'; url = "/deviceAppManagement/mobileApps/$aid/deviceStatuses?`$select=installState&`$top=999" }
|
||||
})
|
||||
$body = @{ requests = $requests } | ConvertTo-Json -Depth 5 -Compress
|
||||
try {
|
||||
# 1. Export-Job anlegen — kein select damit falsche Spaltennamen keinen BadRequest ausloesen
|
||||
$jobJson = '{"reportName":"AppInstallStatusAggregate","filter":""}'
|
||||
|
||||
$job = Invoke-MgGraphRequestRetry `
|
||||
-Uri 'https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs' `
|
||||
-Method POST -Body $jobJson -ContentType 'application/json'
|
||||
|
||||
$jobId = $job.id
|
||||
Write-Host " [REPORT] Export-Job ID: $jobId" -ForegroundColor DarkGray
|
||||
|
||||
# 2. Auf Fertigstellung warten (max. 120s)
|
||||
$status = $job.status
|
||||
$waited = 0
|
||||
while ($status -ne 'completed' -and $status -ne 'failed' -and $waited -lt 120) {
|
||||
Start-Sleep -Seconds 3
|
||||
$waited += 3
|
||||
$job = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$jobId"
|
||||
$status = $job.status
|
||||
Write-Host " [REPORT] Status: $status ($waited s)" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
if ($status -ne 'completed') {
|
||||
throw "Export-Job nicht abgeschlossen (Status: $status nach $waited s)"
|
||||
}
|
||||
|
||||
$downloadUrl = $job.url
|
||||
Write-Host " [REPORT] Download: $downloadUrl" -ForegroundColor DarkGray
|
||||
|
||||
# 3. ZIP herunterladen und CSV parsen
|
||||
$tmpZip = [System.IO.Path]::GetTempFileName() + '.zip'
|
||||
$tmpDir = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "IntuneReport_$jobId")
|
||||
try {
|
||||
$resp = Invoke-MgGraphRequestRetry -Uri 'https://graph.microsoft.com/beta/$batch' -Method POST -Body $body -ContentType 'application/json'
|
||||
foreach ($r in @($resp.responses)) {
|
||||
$status = [int]$r.status
|
||||
if ($status -eq 200) {
|
||||
$aid = [string]$r.id
|
||||
foreach ($ds in @($r.body.value)) {
|
||||
switch ([string]$ds.installState) {
|
||||
'installed' { $counts[$aid].ok++ }
|
||||
'failed' { $counts[$aid].fail++ }
|
||||
'pendingInstall' { $counts[$aid].pending++ }
|
||||
'notInstalled' { $counts[$aid].notInst++ }
|
||||
'notApplicable' { $counts[$aid].notAppl++ }
|
||||
}
|
||||
}
|
||||
Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpZip -UseBasicParsing
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory($tmpZip, $tmpDir)
|
||||
|
||||
$csv = Get-ChildItem -Path $tmpDir -Filter '*.csv' | Select-Object -First 1
|
||||
if (-not $csv) { throw "Keine CSV im Export-ZIP gefunden" }
|
||||
|
||||
$rows = Import-Csv -Path $csv.FullName -Encoding UTF8
|
||||
Write-Host " [REPORT] CSV: $($rows.Count) Zeilen, Spalten: $(($rows[0].PSObject.Properties.Name) -join ',')" -ForegroundColor DarkGray
|
||||
|
||||
foreach ($row in $rows) {
|
||||
$aid = [string]$row.ApplicationId
|
||||
if (-not $aid) { continue }
|
||||
$instV = $row.InstalledDeviceCount; if (-not $instV) { $instV = 0 }
|
||||
$failV = $row.FailedDeviceCount; if (-not $failV) { $failV = 0 }
|
||||
$pendV = $row.PendingInstallDeviceCount; if (-not $pendV) { $pendV = 0 }
|
||||
$notInstV = $row.NotInstalledDeviceCount; if (-not $notInstV) { $notInstV = 0 }
|
||||
$notApplV = $row.NotApplicableDeviceCount;if (-not $notApplV) { $notApplV = 0 }
|
||||
$summaries[$aid] = @{
|
||||
inst = [int]$instV
|
||||
fail = [int]$failV
|
||||
pend = [int]$pendV
|
||||
notInst = [int]$notInstV
|
||||
notAppl = [int]$notApplV
|
||||
}
|
||||
}
|
||||
if ($firstBatch) {
|
||||
$f = $resp.responses[0]
|
||||
Write-Host " [DEBUG] r[0]: status=$($f.status), value.count=$(@($f.body.value).Count)" -ForegroundColor Magenta
|
||||
$firstBatch = $false
|
||||
}
|
||||
} catch {
|
||||
Write-Host " [REPORT] Batch-Fehler: $($_.Exception.Message)" -ForegroundColor DarkYellow
|
||||
}
|
||||
if (($i / $batchSize) % 5 -eq 4) {
|
||||
Write-Host " [REPORT] $([Math]::Min($i + $batchSize, $rawApps.Count))/$($rawApps.Count) Apps..." -ForegroundColor DarkGray
|
||||
Write-Host " [REPORT] $($summaries.Count) Apps mit Install-Daten" -ForegroundColor Green
|
||||
} finally {
|
||||
Remove-Item -Path $tmpZip -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
} catch {
|
||||
$errMsg = $_.Exception.Message
|
||||
# Graph-PS-Modul steckt den Response-Body in $_.Exception.Response
|
||||
try {
|
||||
$stream = $_.Exception.Response.GetResponseStream()
|
||||
$reader = [System.IO.StreamReader]::new($stream)
|
||||
$body = $reader.ReadToEnd()
|
||||
if ($body) { $errMsg += " | Body: $body" }
|
||||
} catch {}
|
||||
try {
|
||||
if ($_.ErrorDetails.Message) { $errMsg += " | Details: $($_.ErrorDetails.Message)" }
|
||||
} catch {}
|
||||
Write-Host " [REPORT] Export-Job fehlgeschlagen: $errMsg" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
$sw.Stop()
|
||||
@@ -1632,7 +1669,7 @@ function Get-AppInstallReportEndpoint {
|
||||
|
||||
$items = @($rawApps | ForEach-Object {
|
||||
$aid = [string]$_.id
|
||||
$c = $counts[$aid]
|
||||
$s = $summaries[$aid]
|
||||
$ver = if ($_.buildNumber) { $_.buildNumber } elseif ($_.versionNumber) { $_.versionNumber } elseif ($_.version) { $_.version } else { '' }
|
||||
[pscustomobject]@{
|
||||
AppId = $aid
|
||||
@@ -1640,11 +1677,11 @@ function Get-AppInstallReportEndpoint {
|
||||
AppType = ([string]$_.('@odata.type') -replace '#microsoft.graph.', '')
|
||||
Publisher = [string]$_.publisher
|
||||
Version = [string]$ver
|
||||
InstalledDeviceCount = $c.ok
|
||||
FailedDeviceCount = $c.fail
|
||||
PendingInstallDeviceCount = $c.pending
|
||||
NotInstalledDeviceCount = $c.notInst
|
||||
NotApplicableDeviceCount = $c.notAppl
|
||||
InstalledDeviceCount = if ($s) { $s.inst } else { 0 }
|
||||
FailedDeviceCount = if ($s) { $s.fail } else { 0 }
|
||||
PendingInstallDeviceCount = if ($s) { $s.pend } else { 0 }
|
||||
NotInstalledDeviceCount = if ($s) { $s.notInst } else { 0 }
|
||||
NotApplicableDeviceCount = if ($s) { $s.notAppl } else { 0 }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1652,6 +1689,7 @@ function Get-AppInstallReportEndpoint {
|
||||
}
|
||||
|
||||
function Get-AppDeviceStatusEndpoint {
|
||||
# Verwendet getDeviceInstallStatusReport (synchron, paginiert) statt deviceStatuses.
|
||||
param([hashtable]$Query)
|
||||
$err = Test-Connected
|
||||
if ($err) { return $err }
|
||||
@@ -1661,28 +1699,61 @@ function Get-AppDeviceStatusEndpoint {
|
||||
return @{ __status = 400; error = "appId fehlt" }
|
||||
}
|
||||
|
||||
Write-Host "[DEVSTATUS] Lade DeviceStatuses fuer App $appId..." -ForegroundColor DarkCyan
|
||||
$uri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$appId/deviceStatuses?`$top=999"
|
||||
$items = @()
|
||||
try {
|
||||
$items = @(Get-GraphPaged -Uri $uri)
|
||||
} catch {
|
||||
return @{ __status = 500; error = "Graph-Fehler: $($_.Exception.Message)" }
|
||||
Write-Host "[DEVSTATUS] Lade Install-Status fuer App $appId via Reports-API..." -ForegroundColor DarkCyan
|
||||
$pageSize = 50
|
||||
$skip = 0
|
||||
$allRows = @()
|
||||
$cols = $null
|
||||
|
||||
do {
|
||||
$bodyJson = "{""filter"":""(ApplicationId eq '$appId')"",""select"":[],""skip"":$skip,""top"":$pageSize,""orderBy"":[]}"
|
||||
try {
|
||||
$resp = Invoke-MgGraphRequestRetry `
|
||||
-Uri 'https://graph.microsoft.com/beta/deviceManagement/reports/getDeviceInstallStatusReport' `
|
||||
-Method POST -Body $bodyJson -ContentType 'application/json'
|
||||
} catch {
|
||||
return @{ __status = 500; error = "Graph-Fehler: $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
if (-not $cols) {
|
||||
$cols = @($resp.Schema | ForEach-Object { $_.Column })
|
||||
Write-Host " [DEVSTATUS] Spalten: $($cols -join ',')" -ForegroundColor DarkGray
|
||||
}
|
||||
$rows = @($resp.Values)
|
||||
$allRows += $rows
|
||||
$skip += $pageSize
|
||||
} while ($rows.Count -eq $pageSize)
|
||||
|
||||
Write-Host " -> $($allRows.Count) Eintraege" -ForegroundColor DarkGray
|
||||
|
||||
if (-not $cols -or $allRows.Count -eq 0) {
|
||||
return @{ items = @(); count = 0 }
|
||||
}
|
||||
|
||||
$result = @($items | ForEach-Object {
|
||||
function ColIdx($name) { [Array]::IndexOf($cols, $name) }
|
||||
$iDevice = ColIdx 'DeviceName'
|
||||
$iUser = ColIdx 'UserName'
|
||||
$iState = ColIdx 'InstallState'
|
||||
$iDetail = ColIdx 'InstallStateDetail'
|
||||
$iErr = ColIdx 'ErrorCode'
|
||||
$iOs = ColIdx 'OSVersion'
|
||||
$iSync = ColIdx 'LastModifiedDateTime'
|
||||
if ($iOs -lt 0) { $iOs = ColIdx 'OsVersion' }
|
||||
if ($iSync -lt 0) { $iSync = ColIdx 'LastSyncDateTime' }
|
||||
|
||||
$result = @($allRows | ForEach-Object {
|
||||
$r = $_
|
||||
[pscustomobject]@{
|
||||
DeviceName = [string]$_.deviceName
|
||||
UserName = [string]$_.userName
|
||||
InstallState = [string]$_.installState
|
||||
InstallStateDetail = [string]$_.installStateDetail
|
||||
ErrorCode = [string]$_.errorCode
|
||||
LastSyncDateTime = [string]$_.lastSyncDateTime
|
||||
OsVersion = [string]$_.osVersion
|
||||
DeviceName = if ($iDevice -ge 0) { [string]$r[$iDevice] } else { '' }
|
||||
UserName = if ($iUser -ge 0) { [string]$r[$iUser] } else { '' }
|
||||
InstallState = if ($iState -ge 0) { [string]$r[$iState] } else { '' }
|
||||
InstallStateDetail = if ($iDetail -ge 0) { [string]$r[$iDetail] } else { '' }
|
||||
ErrorCode = if ($iErr -ge 0) { [string]$r[$iErr] } else { '' }
|
||||
OsVersion = if ($iOs -ge 0) { [string]$r[$iOs] } else { '' }
|
||||
LastSyncDateTime = if ($iSync -ge 0) { [string]$r[$iSync] } else { '' }
|
||||
}
|
||||
})
|
||||
|
||||
Write-Host " -> $($result.Count) Eintraege" -ForegroundColor DarkGray
|
||||
return @{ items = $result; count = $result.Count }
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -194,12 +194,15 @@ function Search-GraphUser {
|
||||
if ($cfgFields.Count -eq 0) { $cfgFields = @('displayName','userPrincipalName','mail') }
|
||||
|
||||
# 1) startswith — schnell, deckt Praefix-Tippen ab (90%+ aller Suchen)
|
||||
# Bei Umlauten/Nicht-ASCII: ConsistencyLevel + $count erforderlich,
|
||||
# sonst liefert Graph leere Ergebnisse oder 400.
|
||||
$hasNonAscii = $term -match '[^\x00-\x7F]'
|
||||
try {
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$parts = @($cfgFields | ForEach-Object { "startswith($_,'$term')" })
|
||||
$filter = $parts -join " or "
|
||||
$uri = "https://graph.microsoft.com/v1.0/users?`$select=$select&`$filter=$([uri]::EscapeDataString($filter))&`$top=25"
|
||||
$resp = Invoke-MgGraphRequest -Uri $uri -Method GET
|
||||
$uri = "https://graph.microsoft.com/v1.0/users?`$select=$select&`$filter=$([uri]::EscapeDataString($filter))&`$top=25&`$count=true"
|
||||
$resp = Invoke-MgGraphRequest -Uri $uri -Method GET -Headers @{ ConsistencyLevel = "eventual" }
|
||||
$items = @()
|
||||
if ($resp -and $resp.value) { $items = @($resp.value) }
|
||||
$sw.Stop()
|
||||
|
||||
+100
-4
@@ -3290,8 +3290,9 @@ function renderMembersList() {
|
||||
if (e.target.checked) mmContext.selectedIds.add(id);
|
||||
else mmContext.selectedIds.delete(id);
|
||||
row.classList.toggle('selected', e.target.checked);
|
||||
document.getElementById('mmInfo').textContent =
|
||||
mmContext.selectedIds.size === 0 ? 'Keine ausgewählt' : `${mmContext.selectedIds.size} ausgewählt`;
|
||||
const n = mmContext.selectedIds.size;
|
||||
document.getElementById('mmInfo').textContent = n === 0 ? 'Keine ausgewählt' : `${n} ausgewählt`;
|
||||
document.getElementById('mmRemove').disabled = n === 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3314,6 +3315,43 @@ document.getElementById('mmClear')?.addEventListener('click', () => {
|
||||
mmContext.selectedIds.clear();
|
||||
renderMembersList();
|
||||
document.getElementById('mmInfo').textContent = 'Keine ausgewählt';
|
||||
document.getElementById('mmRemove').disabled = true;
|
||||
});
|
||||
|
||||
document.getElementById('mmRemove')?.addEventListener('click', async () => {
|
||||
const n = mmContext.selectedIds.size;
|
||||
if (n === 0) return;
|
||||
const names = mmContext.members
|
||||
.filter(m => mmContext.selectedIds.has(m.Id))
|
||||
.map(m => m.DisplayName || m.UserPrincipalName)
|
||||
.slice(0, 5).join('\n');
|
||||
const more = n > 5 ? `\n… und ${n - 5} weitere` : '';
|
||||
if (!confirm(`${n} Mitglied${n > 1 ? 'er' : ''} aus "${mmContext.groupName}" entfernen?\n\n${names}${more}`)) return;
|
||||
|
||||
const btn = document.getElementById('mmRemove');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Entferne…';
|
||||
|
||||
const ids = [...mmContext.selectedIds];
|
||||
let ok = 0, fail = 0;
|
||||
for (const userId of ids) {
|
||||
try {
|
||||
await api(`/api/groups/${encodeURIComponent(mmContext.groupId)}/members/${encodeURIComponent(userId)}`, { method: 'DELETE' });
|
||||
mmContext.members = mmContext.members.filter(m => m.Id !== userId);
|
||||
mmContext.selectedIds.delete(userId);
|
||||
ok++;
|
||||
} catch (e) {
|
||||
fail++;
|
||||
console.error('Remove member failed:', userId, e);
|
||||
}
|
||||
}
|
||||
|
||||
btn.textContent = 'Aus Gruppe entfernen';
|
||||
document.getElementById('mmInfo').textContent = 'Keine ausgewählt';
|
||||
renderMembersList();
|
||||
|
||||
if (fail === 0) toast(`${ok} Mitglied${ok > 1 ? 'er' : ''} entfernt`, 'ok');
|
||||
else toast(`${ok} entfernt, ${fail} fehlgeschlagen`, 'warn');
|
||||
});
|
||||
|
||||
document.getElementById('mmApply')?.addEventListener('click', () => {
|
||||
@@ -4488,25 +4526,83 @@ function _gexRenderMembers() {
|
||||
if (m.duplicates > 0) html += ` (${m.duplicates} Duplikate aus verschachtelten Gruppen zusammengeführt)`;
|
||||
html += '</div>';
|
||||
|
||||
const directGroupName = m.groupName || '';
|
||||
for (const [path, users] of byPath.entries()) {
|
||||
const isDirect = path === directGroupName;
|
||||
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)}">
|
||||
const cbx = isDirect
|
||||
? `<input type="checkbox" class="gex-member-chk" data-uid="${escapeHtml(u.Id)}" data-name="${escapeHtml(u.DisplayName || upn)}">`
|
||||
: `<span style="width:14px;flex-shrink:0;"></span>`;
|
||||
html += `<label class="gex-user-item" data-search="${escapeHtml(searchKey)}">
|
||||
${cbx}
|
||||
<span class="gex-user-name">${escapeHtml(u.DisplayName || '—')}</span>
|
||||
<span class="gex-user-upn">${escapeHtml(upn)}</span>
|
||||
</div>`;
|
||||
</label>`;
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
area.innerHTML = html;
|
||||
if (GexState.memberFilter) _gexApplyFilter();
|
||||
|
||||
// Checkboxen verdrahten
|
||||
const removeBtn = document.getElementById('gexRemoveBtn');
|
||||
if (removeBtn) removeBtn.style.display = '';
|
||||
area.querySelectorAll('.gex-member-chk').forEach(chk => {
|
||||
chk.addEventListener('change', () => {
|
||||
const n = area.querySelectorAll('.gex-member-chk:checked').length;
|
||||
if (removeBtn) {
|
||||
removeBtn.disabled = n === 0;
|
||||
removeBtn.textContent = n > 0 ? `${n} Mitglied${n > 1 ? 'er' : ''} entfernen` : 'Aus Gruppe entfernen';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Mitglieder entfernen ----
|
||||
document.getElementById('gexRemoveBtn')?.addEventListener('click', async () => {
|
||||
const area = document.getElementById('gexMembersArea');
|
||||
const checked = [...(area?.querySelectorAll('.gex-member-chk:checked') || [])];
|
||||
if (!checked.length || !GexState.selectedGroup) return;
|
||||
|
||||
const names = checked.slice(0, 5).map(c => c.dataset.name).join('\n');
|
||||
const more = checked.length > 5 ? `\n… und ${checked.length - 5} weitere` : '';
|
||||
if (!confirm(`${checked.length} Mitglied${checked.length > 1 ? 'er' : ''} aus "${GexState.selectedGroup.DisplayName}" entfernen?\n\n${names}${more}`)) return;
|
||||
|
||||
const btn = document.getElementById('gexRemoveBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Entferne…';
|
||||
const groupId = GexState.selectedGroup.Id;
|
||||
|
||||
let ok = 0, fail = 0;
|
||||
for (const chk of checked) {
|
||||
try {
|
||||
await api(`/api/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(chk.dataset.uid)}`, { method: 'DELETE' });
|
||||
chk.closest('label').remove();
|
||||
ok++;
|
||||
} catch (e) {
|
||||
fail++;
|
||||
console.error('Remove failed:', chk.dataset.uid, e);
|
||||
}
|
||||
}
|
||||
|
||||
btn.textContent = 'Aus Gruppe entfernen';
|
||||
btn.disabled = true;
|
||||
if (fail === 0) toast(`${ok} Mitglied${ok > 1 ? 'er' : ''} entfernt`, 'ok');
|
||||
else toast(`${ok} entfernt, ${fail} fehlgeschlagen`, 'warn');
|
||||
|
||||
// Mitgliederzahl aktualisieren
|
||||
if (GexState.members) {
|
||||
GexState.members.count = Math.max(0, GexState.members.count - ok);
|
||||
document.getElementById('gexStats').textContent = `${GexState.members.count} Benutzer`;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Benutzer-Suche im Add-Tab ----
|
||||
|
||||
let _gexUserSearchTimer = null;
|
||||
|
||||
+3
-1
@@ -318,7 +318,8 @@
|
||||
<div class="gex-hint">Gruppe auswählen um Mitglieder zu laden.</div>
|
||||
</div>
|
||||
<div class="group-mgmt-foot">
|
||||
<span id="gexStats" class="muted"></span>
|
||||
<span id="gexStats" class="muted" style="margin-right:auto;"></span>
|
||||
<button class="btn btn-danger btn-sm" id="gexRemoveBtn" disabled style="display:none;">Aus Gruppe entfernen</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
|
||||
@@ -597,6 +598,7 @@
|
||||
<div class="modal-foot">
|
||||
<span id="mmInfo" class="muted" style="margin-right: auto;">0 ausgewählt</span>
|
||||
<button class="btn btn-secondary" data-close>Abbrechen</button>
|
||||
<button class="btn btn-danger" id="mmRemove" disabled>Aus Gruppe entfernen</button>
|
||||
<button class="btn btn-primary" id="mmApply">Als Benutzer-Auswahl übernehmen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+15
-1
@@ -506,6 +506,14 @@ html[data-theme="dark"] .theme-ico-moon { opacity: 1; }
|
||||
border-color: var(--surface-strong);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
border-color: var(--error);
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.btn-danger:disabled { opacity: .45; }
|
||||
|
||||
.btn-text {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
@@ -4674,12 +4682,18 @@ html[data-theme="dark"] .numInputWrapper span.arrowDown {
|
||||
|
||||
.gex-user-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--hairline-soft);
|
||||
font-size: 13px;
|
||||
cursor: default;
|
||||
}
|
||||
.gex-user-item input[type="checkbox"] {
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gex-user-item input[type="checkbox"] + .gex-user-name { cursor: pointer; }
|
||||
.gex-user-item:last-child { border-bottom: none; }
|
||||
.gex-user-item.hidden-filter { display: none; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user