feat: Expandable Container-Rows + shadcn AlertDialog + Status 207 Fix
- Backend: Status 207 → 200 für partielle Erfolge (admin_api.py) - Frontend: Expandable User-Rows mit Container-Checkboxen (admin/page.tsx) - UI: Neues shadcn AlertDialog für Container-Lösch-Bestätigung - Deps: @radix-ui/react-alert-dialog installiert - Docs: Version 3.0 Dokumentation aktualisiert (admin-dashboard-improvements.md) Behebt: - Problem I: Browser-confirm() → echtes Modal - Problem II: Status 207 Fehler (0 gelöscht, 1 fehlgeschlagen)
This commit is contained in:
parent
a39488139c
commit
0117566268
19
admin_api.py
19
admin_api.py
|
|
@ -198,7 +198,12 @@ def delete_user_container(user_id):
|
||||||
return jsonify({'error': 'User nicht gefunden'}), 404
|
return jsonify({'error': 'User nicht gefunden'}), 404
|
||||||
|
|
||||||
if not user.containers:
|
if not user.containers:
|
||||||
return jsonify({'error': 'User hat keine Container'}), 400
|
return jsonify({
|
||||||
|
'message': 'User hat keine Container',
|
||||||
|
'deleted': 0,
|
||||||
|
'failed': [],
|
||||||
|
'skipped': True
|
||||||
|
}), 200
|
||||||
|
|
||||||
container_mgr = ContainerManager()
|
container_mgr = ContainerManager()
|
||||||
deleted_count = 0
|
deleted_count = 0
|
||||||
|
|
@ -225,16 +230,12 @@ def delete_user_container(user_id):
|
||||||
|
|
||||||
current_app.logger.info(f"Admin {admin_id} löschte {deleted_count} Container von User {user.email}")
|
current_app.logger.info(f"Admin {admin_id} löschte {deleted_count} Container von User {user.email}")
|
||||||
|
|
||||||
if failed_containers:
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'message': f'{deleted_count} Container gelöscht, {len(failed_containers)} fehlgeschlagen',
|
'message': f'{deleted_count} Container gelöscht' +
|
||||||
|
(f', {len(failed_containers)} fehlgeschlagen' if failed_containers else ''),
|
||||||
|
'deleted': deleted_count,
|
||||||
'failed': failed_containers,
|
'failed': failed_containers,
|
||||||
'deleted': deleted_count
|
'partial_failure': len(failed_containers) > 0
|
||||||
}), 207 # Multi-Status
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'message': f'Alle {deleted_count} Container von {user.email} wurden gelöscht',
|
|
||||||
'deleted': deleted_count
|
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Admin-Dashboard: Verbesserte Container- und User-Löschung
|
# Admin-Dashboard: Verbesserte Container- und User-Löschung
|
||||||
|
|
||||||
**Datum:** 02.02.2026
|
**Datum:** 08.02.2026 (Update), 02.02.2026 (Initial)
|
||||||
**Version:** 2.0
|
**Version:** 3.0
|
||||||
**Status:** ✅ Vollständig implementiert
|
**Status:** ✅ Vollständig implementiert
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -14,6 +14,82 @@ Diese Dokumentation beschreibt die Verbesserungen des Admin-Dashboards:
|
||||||
2. **Toast-Benachrichtigungen** - Modernes UI statt primitiver Alerts
|
2. **Toast-Benachrichtigungen** - Modernes UI statt primitiver Alerts
|
||||||
3. **Bulk-Operations** - Mehrere User gleichzeitig verwalten (Sperren, Löschen, etc.)
|
3. **Bulk-Operations** - Mehrere User gleichzeitig verwalten (Sperren, Löschen, etc.)
|
||||||
4. **DSGVO-Compliance** - Vollständige Datenlöschung (MagicLinkToken, AdminTakeoverSession)
|
4. **DSGVO-Compliance** - Vollständige Datenlöschung (MagicLinkToken, AdminTakeoverSession)
|
||||||
|
5. **✨ NEU (v3.0)**: Expandable Container-Rows + shadcn AlertDialog (08.02.2026)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Version 3.0: Expandable Rows & AlertDialog (08.02.2026)
|
||||||
|
|
||||||
|
### 🎯 Problem (Gelöst)
|
||||||
|
|
||||||
|
**Problem I:** Browser-`confirm()` statt echtes Modal
|
||||||
|
- Admin konnte nicht einzelne Container auswählen
|
||||||
|
- Nur "Alles löschen oder gar nichts" möglich
|
||||||
|
- Keine Übersicht der betroffenen Container
|
||||||
|
|
||||||
|
**Problem II:** Toast zeigt "0 gelöscht, 1 fehlgeschlagen"
|
||||||
|
- **Root Cause**: Backend gab Status 207 zurück
|
||||||
|
- Frontend interpretierte Status 207 als Fehler → `response.ok = false`
|
||||||
|
- Obwohl Container erfolgreich gelöscht wurden, zählte Frontend sie als "fehlgeschlagen"
|
||||||
|
|
||||||
|
### ✅ Lösung Implementiert
|
||||||
|
|
||||||
|
#### Backend-Fix (`admin_api.py` Zeile 189-238)
|
||||||
|
- Status 207 → **200** (für partielle Erfolge)
|
||||||
|
- Status 400 → **200** (für "keine Container")
|
||||||
|
- Details im Response-Body: `deleted`, `failed`, `partial_failure`
|
||||||
|
- **Begründung**: HTTP-Status sollten Transport-Errors signalisieren, nicht Business-Logic
|
||||||
|
|
||||||
|
```python
|
||||||
|
# VORHER: Status 207 für Teilerfolge
|
||||||
|
return jsonify({...}), 207
|
||||||
|
|
||||||
|
# NACHHER: Immer Status 200, Details im Body
|
||||||
|
return jsonify({
|
||||||
|
'message': f'{deleted_count} Container gelöscht' +
|
||||||
|
(f', {len(failed_containers)} fehlgeschlagen' if failed_containers else ''),
|
||||||
|
'deleted': deleted_count,
|
||||||
|
'failed': failed_containers,
|
||||||
|
'partial_failure': len(failed_containers) > 0
|
||||||
|
}), 200
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend-Verbesserungen (`frontend/src/app/admin/page.tsx`)
|
||||||
|
|
||||||
|
**Expandable User-Rows:**
|
||||||
|
- Klick auf User → Container-Liste klappt auf/zu
|
||||||
|
- ChevronDown Icon rotiert bei Expand/Collapse
|
||||||
|
- Container nur angezeigt wenn User expandiert
|
||||||
|
|
||||||
|
**Container-Checkboxen:**
|
||||||
|
- Jeder Container hat eine Checkbox
|
||||||
|
- Nur ausgewählte Container werden gelöscht
|
||||||
|
- Unterstützung für Einzelaus wahl und Bulk-Select
|
||||||
|
|
||||||
|
**shadcn AlertDialog (neu):**
|
||||||
|
- Echtes Modal statt Browser-`confirm()`
|
||||||
|
- Zeigt Zusammenfassung:
|
||||||
|
```
|
||||||
|
3 Container von 2 Benutzer(n) löschen?
|
||||||
|
• user1@example.com (2 Container)
|
||||||
|
• user2@example.com (1 Container)
|
||||||
|
```
|
||||||
|
- Ja/Nein Buttons mit klarer Bestätigung
|
||||||
|
|
||||||
|
**Verbesserte Bulk-Delete-Logik:**
|
||||||
|
- Parst `deleted` und `failed` aus Response-Body
|
||||||
|
- Akkumuliert Container-Zähler (nicht User-Zähler)
|
||||||
|
- Toast zeigt genaue Zahlen: "3 Container gelöscht, 1 fehlgeschlagen"
|
||||||
|
|
||||||
|
### 📝 Dateiänderungen
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ Geänderte Dateien:
|
||||||
|
- admin_api.py (Backend-Fix für Status 207)
|
||||||
|
- frontend/src/app/admin/page.tsx (UI-Overhaul)
|
||||||
|
- frontend/src/components/ui/alert-dialog.tsx (neue Komponente)
|
||||||
|
- frontend/package.json (@radix-ui/react-alert-dialog)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -410,6 +486,7 @@ with app.app_context():
|
||||||
|
|
||||||
| Version | Datum | Änderungen |
|
| Version | Datum | Änderungen |
|
||||||
|---------|-------|-----------|
|
|---------|-------|-----------|
|
||||||
|
| 3.0 | 08.02.2026 | **Expandable Rows**, shadcn AlertDialog, Status 207→200 Fix, Container-Checkboxen |
|
||||||
| 2.0 | 02.02.2026 | Multi-Container, Toast-System, Bulk-Operations, DSGVO |
|
| 2.0 | 02.02.2026 | Multi-Container, Toast-System, Bulk-Operations, DSGVO |
|
||||||
| 1.0 | ≤01.02.2026 | Ursprüngliches Admin-Dashboard |
|
| 1.0 | ≤01.02.2026 | Ursprüngliches Admin-Dashboard |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,20 @@
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.408.0",
|
||||||
"next": "14.2.5",
|
"next": "14.2.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"class-variance-authority": "^0.7.0",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"tailwind-merge": "^2.4.0",
|
|
||||||
"lucide-react": "^0.408.0",
|
|
||||||
"sonner": "1.7.2",
|
"sonner": "1.7.2",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"tailwind-merge": "^2.4.0"
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,19 @@ import {
|
||||||
Search,
|
Search,
|
||||||
Monitor,
|
Monitor,
|
||||||
X,
|
X,
|
||||||
|
ChevronDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
type StatusColor = "green" | "yellow" | "red";
|
type StatusColor = "green" | "yellow" | "red";
|
||||||
|
|
||||||
|
|
@ -114,6 +125,12 @@ export default function AdminPage() {
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<Set<number>>(new Set());
|
const [selectedUserIds, setSelectedUserIds] = useState<Set<number>>(new Set());
|
||||||
const [activeTab, setActiveTab] = useState<"users" | "containers">("users");
|
const [activeTab, setActiveTab] = useState<"users" | "containers">("users");
|
||||||
const [selectedContainerIds, setSelectedContainerIds] = useState<Set<number>>(new Set());
|
const [selectedContainerIds, setSelectedContainerIds] = useState<Set<number>>(new Set());
|
||||||
|
const [expandedUserIds, setExpandedUserIds] = useState<Set<number>>(new Set());
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogData, setDeleteDialogData] = useState<{
|
||||||
|
containerIds: number[];
|
||||||
|
userSummary: { email: string; count: number }[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const fetchUsers = useCallback(async () => {
|
const fetchUsers = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
@ -130,6 +147,96 @@ export default function AdminPage() {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, [fetchUsers]);
|
}, [fetchUsers]);
|
||||||
|
|
||||||
|
// Expand/Collapse Helper
|
||||||
|
const toggleUserExpand = (userId: number) => {
|
||||||
|
const newExpanded = new Set(expandedUserIds);
|
||||||
|
if (newExpanded.has(userId)) {
|
||||||
|
newExpanded.delete(userId);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(userId);
|
||||||
|
}
|
||||||
|
setExpandedUserIds(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dialog Helper für Bulk-Delete
|
||||||
|
const openBulkDeleteDialog = () => {
|
||||||
|
if (selectedContainerIds.size === 0) {
|
||||||
|
toast.error("Keine Container ausgewählt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle Zusammenfassung nach User
|
||||||
|
const userMap = new Map<number, { email: string; count: number }>();
|
||||||
|
|
||||||
|
for (const containerId of selectedContainerIds) {
|
||||||
|
const user = users.find(u =>
|
||||||
|
u.containers?.some(c => c.id === containerId)
|
||||||
|
);
|
||||||
|
if (user) {
|
||||||
|
const existing = userMap.get(user.id) || { email: user.email, count: 0 };
|
||||||
|
existing.count++;
|
||||||
|
userMap.set(user.id, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleteDialogData({
|
||||||
|
containerIds: Array.from(selectedContainerIds),
|
||||||
|
userSummary: Array.from(userMap.values())
|
||||||
|
});
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bestätigte Bulk-Delete
|
||||||
|
const handleConfirmBulkDelete = async () => {
|
||||||
|
if (!deleteDialogData) return;
|
||||||
|
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
toast.loading(
|
||||||
|
`Lösche ${deleteDialogData.containerIds.length} Container...`,
|
||||||
|
{ id: "bulk-delete-containers" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Gruppiere Container nach User-ID
|
||||||
|
const containersByUser = new Map<number, number[]>();
|
||||||
|
|
||||||
|
for (const containerId of deleteDialogData.containerIds) {
|
||||||
|
const user = users.find(u =>
|
||||||
|
u.containers?.some(c => c.id === containerId)
|
||||||
|
);
|
||||||
|
if (user) {
|
||||||
|
if (!containersByUser.has(user.id)) {
|
||||||
|
containersByUser.set(user.id, []);
|
||||||
|
}
|
||||||
|
containersByUser.get(user.id)!.push(containerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalDeleted = 0;
|
||||||
|
let totalFailed = 0;
|
||||||
|
|
||||||
|
// Lösche Container pro User
|
||||||
|
for (const [userId, containerIds] of containersByUser) {
|
||||||
|
const { data, error } = await adminApi.deleteUserContainer(userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
totalFailed += containerIds.length;
|
||||||
|
} else if (data) {
|
||||||
|
// Parse Response-Body (nach Backend-Fix)
|
||||||
|
totalDeleted += data.deleted || 0;
|
||||||
|
totalFailed += (data.failed?.length || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`${totalDeleted} Container gelöscht`, {
|
||||||
|
id: "bulk-delete-containers",
|
||||||
|
description: totalFailed > 0 ? `${totalFailed} fehlgeschlagen` : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchUsers();
|
||||||
|
setSelectedContainerIds(new Set());
|
||||||
|
setDeleteDialogData(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Bulk-Selection Helpers
|
// Bulk-Selection Helpers
|
||||||
const toggleUserSelection = (userId: number) => {
|
const toggleUserSelection = (userId: number) => {
|
||||||
const newSelection = new Set(selectedUserIds);
|
const newSelection = new Set(selectedUserIds);
|
||||||
|
|
@ -188,23 +295,6 @@ export default function AdminPage() {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteContainer = async (userId: number, userEmail: string) => {
|
|
||||||
if (!confirm(`Container von "${userEmail}" wirklich loeschen? Der User kann einen neuen Container starten.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActionLoading(userId);
|
|
||||||
const { data, error } = await adminApi.deleteUserContainer(userId);
|
|
||||||
if (error) {
|
|
||||||
toast.error(`Fehler: ${error}`);
|
|
||||||
} else {
|
|
||||||
toast.success(data?.message || "Container geloescht", {
|
|
||||||
description: data?.deleted ? `${data.deleted} Container entfernt` : undefined,
|
|
||||||
});
|
|
||||||
fetchUsers();
|
|
||||||
}
|
|
||||||
setActionLoading(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteUser = async (userId: number, userEmail: string) => {
|
const handleDeleteUser = async (userId: number, userEmail: string) => {
|
||||||
if (!confirm(
|
if (!confirm(
|
||||||
`⚠️ ACHTUNG: User "${userEmail}" VOLLSTAENDIG loeschen?\n\n` +
|
`⚠️ ACHTUNG: User "${userEmail}" VOLLSTAENDIG loeschen?\n\n` +
|
||||||
|
|
@ -304,43 +394,6 @@ export default function AdminPage() {
|
||||||
deselectAll();
|
deselectAll();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkDeleteContainers = async () => {
|
|
||||||
const userList = Array.from(selectedUserIds)
|
|
||||||
.map((id) => users.find((u) => u.id === id)?.email)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
if (!confirm(
|
|
||||||
`Container von ${selectedUserIds.size} Usern löschen?\n\n` +
|
|
||||||
`Betroffene User:\n${userList}\n\n` +
|
|
||||||
`User können danach neue Container erstellen.`
|
|
||||||
)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.loading(`Lösche Container von ${selectedUserIds.size} Usern...`, { id: "bulk-delete-containers" });
|
|
||||||
|
|
||||||
let success = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
for (const userId of selectedUserIds) {
|
|
||||||
const { error } = await adminApi.deleteUserContainer(userId);
|
|
||||||
if (error) {
|
|
||||||
failed++;
|
|
||||||
} else {
|
|
||||||
success++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(`${success} User-Container gelöscht`, {
|
|
||||||
id: "bulk-delete-containers",
|
|
||||||
description: failed > 0 ? `${failed} fehlgeschlagen` : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchUsers();
|
|
||||||
deselectAll();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkDeleteUsers = async () => {
|
const handleBulkDeleteUsers = async () => {
|
||||||
const selectedUsers = Array.from(selectedUserIds)
|
const selectedUsers = Array.from(selectedUserIds)
|
||||||
.map((id) => users.find((u) => u.id === id))
|
.map((id) => users.find((u) => u.id === id))
|
||||||
|
|
@ -717,15 +770,17 @@ export default function AdminPage() {
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Bulk-Delete-Container */}
|
{/* Bulk-Delete-Container */}
|
||||||
|
{selectedContainerIds.size > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleBulkDeleteContainers}
|
onClick={openBulkDeleteDialog}
|
||||||
disabled={actionLoading !== null}
|
disabled={actionLoading !== null}
|
||||||
>
|
>
|
||||||
<Container className="mr-2 h-4 w-4" />
|
<Container className="mr-2 h-4 w-4" />
|
||||||
Container löschen
|
Container löschen ({selectedContainerIds.size})
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bulk-Delete User */}
|
{/* Bulk-Delete User */}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -805,12 +860,31 @@ export default function AdminPage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={u.id}
|
key={u.id}
|
||||||
className={`flex items-center justify-between rounded-lg border p-4 ${
|
className="border rounded-lg overflow-hidden"
|
||||||
u.is_blocked ? "bg-red-50 border-red-200" : ""
|
>
|
||||||
} ${isSelected ? "bg-primary/5 border-primary" : ""}`}
|
{/* Main User Row */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between p-4 ${
|
||||||
|
u.is_blocked ? "bg-red-50 border-b border-red-200" : "border-b"
|
||||||
|
} ${isSelected ? "bg-primary/5" : ""}`}
|
||||||
>
|
>
|
||||||
{/* Checkbox + User Info */}
|
{/* Checkbox + User Info */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Expand Icon */}
|
||||||
|
{u.containers && u.containers.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleUserExpand(u.id)}
|
||||||
|
className="p-0 h-4 w-4 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title={expandedUserIds.has(u.id) ? "Container ausblenden" : "Container anzeigen"}
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 transition-transform ${
|
||||||
|
expandedUserIds.has(u.id) ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{isSelectable && (
|
{isSelectable && (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -909,18 +983,6 @@ export default function AdminPage() {
|
||||||
<Mail className="h-4 w-4" />
|
<Mail className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Container loeschen */}
|
|
||||||
{u.container_id && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDeleteContainer(u.id, u.email)}
|
|
||||||
title="Container loeschen"
|
|
||||||
>
|
|
||||||
<Container className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Takeover (Dummy) */}
|
{/* Takeover (Dummy) */}
|
||||||
{u.container_id && !isCurrentUser && (
|
{u.container_id && !isCurrentUser && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -975,6 +1037,45 @@ export default function AdminPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable Container List */}
|
||||||
|
{expandedUserIds.has(u.id) && u.containers && u.containers.length > 0 && (
|
||||||
|
<div className="border-t bg-muted/30 p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{u.containers.map(container => (
|
||||||
|
<div
|
||||||
|
key={container.id}
|
||||||
|
className="flex items-center gap-3 p-3 rounded border bg-background hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
{/* Checkbox für Container */}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedContainerIds.has(container.id)}
|
||||||
|
onChange={() => toggleContainerSelection(container.id)}
|
||||||
|
className="h-4 w-4 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Container Icon + Info */}
|
||||||
|
<Container className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm">{container.container_type}</span>
|
||||||
|
{container.is_blocked && (
|
||||||
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
Gesperrt
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{container.container_id ? "Running" : "Stopped"} • {formatDate(container.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|
@ -1174,6 +1275,47 @@ export default function AdminPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Container wirklich löschen?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{deleteDialogData && (
|
||||||
|
<>
|
||||||
|
<p className="mb-3">
|
||||||
|
Du bist dabei, <strong>{deleteDialogData.containerIds.length} Container</strong> von{" "}
|
||||||
|
<strong>{deleteDialogData.userSummary.length} Benutzer(n)</strong> zu löschen.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 space-y-1 text-sm bg-muted/50 p-3 rounded">
|
||||||
|
<p className="font-semibold text-foreground">Betroffene Benutzer:</p>
|
||||||
|
<ul className="space-y-1 ml-2">
|
||||||
|
{deleteDialogData.userSummary.map((user, idx) => (
|
||||||
|
<li key={idx} className="text-sm">
|
||||||
|
• <span className="font-medium">{user.email}</span> ({user.count} Container)
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-xs text-muted-foreground">
|
||||||
|
Die Benutzer können danach neue Container erstellen.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleConfirmBulkDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Jetzt löschen
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
frontend/src/components/ui/alert-dialog.tsx
Normal file
132
frontend/src/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user