add dashboard pages for permission scanner, info panels, and ticket categories
Bring the command-only permission scanner, info panel builder, and ticket role/template config to the dashboard. Also fixes /settings silently dropping brandingConfig/partnerConfig/galleryConfig/imageModerationConfig/ ticketConfig/lockdownConfig on save. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,8 @@ import { Watchlist } from './pages/Watchlist';
|
||||
import { Admin } from './pages/Admin';
|
||||
import { Branding } from './pages/Branding';
|
||||
import { Growth } from './pages/Growth';
|
||||
import { Permissions } from './pages/Permissions';
|
||||
import { Panels } from './pages/Panels';
|
||||
|
||||
const THEME_COLORS: Record<string, string> = {
|
||||
orange: '#f97316',
|
||||
@@ -68,6 +70,8 @@ function AppContent() {
|
||||
case 'watchlist': return <Watchlist />;
|
||||
case 'branding': return <Branding />;
|
||||
case 'growth': return <Growth />;
|
||||
case 'permissions': return <Permissions />;
|
||||
case 'panels': return <Panels />;
|
||||
case 'admin': return <Admin />;
|
||||
default: return <Dashboard />;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import {
|
||||
LogOut, PanelLeftClose, PanelLeft, Activity, AudioLines, CalendarDays,
|
||||
ClipboardList, Eye, Home, LogIn, ListChecks, Music, Palette, Puzzle, RadioTower, Settings,
|
||||
Shield, Sparkles, Tag, Ticket, TrendingUp, Wrench
|
||||
Shield, ShieldAlert, Sparkles, Tag, Ticket, TrendingUp, LayoutPanelTop, Wrench
|
||||
} from 'lucide-react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { AppAvatar } from '../shared/AppAvatar';
|
||||
@@ -25,6 +25,7 @@ const navGroups = [
|
||||
{ key: 'birthday', label: 'Birthday', icon: <CalendarDays size={18} /> },
|
||||
{ key: 'events', label: 'Events', icon: <Activity size={18} /> },
|
||||
{ key: 'reactionroles', label: 'Reaction Roles', icon: <Tag size={18} /> },
|
||||
{ key: 'panels', label: 'Info-Panels', icon: <LayoutPanelTop size={18} /> },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -39,6 +40,7 @@ const navGroups = [
|
||||
label: 'Moderation',
|
||||
items: [
|
||||
{ key: 'automod', label: 'Automod', icon: <Shield size={18} /> },
|
||||
{ key: 'permissions', label: 'Rechte-Scanner', icon: <ShieldAlert size={18} /> },
|
||||
{ key: 'tasks', label: 'Team-Aufgaben', icon: <ListChecks size={18} /> },
|
||||
{ key: 'watchlist', label: 'Watchlist', icon: <Eye size={18} /> },
|
||||
]
|
||||
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
EventItem, ReactionRoleSet, ModuleItem, LogEntry, SettingsState,
|
||||
SupportLoginConfig, SupportLoginStatus, RegisterForm, RegisterFormField,
|
||||
RegisterApplication, MusicSession, StaffTask, WatchlistEntry, GrowthStats,
|
||||
InviteBreakdownEntry, RecentJoinEntry
|
||||
InviteBreakdownEntry, RecentJoinEntry, PermissionScanResult, InfoPanel, InfoPanelType,
|
||||
TicketTopicConfig
|
||||
} from '../types';
|
||||
|
||||
const appConfig: AppConfig = (window as any).__PAPO__ || {};
|
||||
@@ -51,6 +52,12 @@ type AppState = {
|
||||
growthStats: GrowthStats | null;
|
||||
inviteBreakdown: InviteBreakdownEntry[];
|
||||
recentJoins: RecentJoinEntry[];
|
||||
permissionScan: PermissionScanResult | null;
|
||||
permissionScanLoading: boolean;
|
||||
infoPanels: InfoPanel[];
|
||||
panelDraft: { type: InfoPanelType; channelId: string; title: string; description: string; items: string };
|
||||
ticketTopics: Record<string, TicketTopicConfig>;
|
||||
ticketTopicDraft: { topic: string; roleId: string; questions: string };
|
||||
};
|
||||
|
||||
type AppContextType = AppState & {
|
||||
@@ -135,6 +142,14 @@ type AppContextType = AppState & {
|
||||
removeFromWatchlist: (userId: string) => Promise<void>;
|
||||
loadGrowthStats: () => Promise<void>;
|
||||
loadInviteBreakdown: () => Promise<void>;
|
||||
loadPermissionScan: () => Promise<void>;
|
||||
loadInfoPanels: () => Promise<void>;
|
||||
setPanelDraft: (s: any | ((prev: any) => any)) => void;
|
||||
createInfoPanel: () => Promise<void>;
|
||||
deleteInfoPanel: (id: string) => Promise<void>;
|
||||
loadTicketTopics: () => Promise<void>;
|
||||
setTicketTopicDraft: (s: any | ((prev: any) => any)) => void;
|
||||
saveTicketTopic: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AppContext = createContext<AppContextType | null>(null);
|
||||
@@ -196,6 +211,12 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const [growthStats, setGrowthStats] = useState<GrowthStats | null>(null);
|
||||
const [inviteBreakdown, setInviteBreakdown] = useState<InviteBreakdownEntry[]>([]);
|
||||
const [recentJoins, setRecentJoins] = useState<RecentJoinEntry[]>([]);
|
||||
const [permissionScan, setPermissionScan] = useState<PermissionScanResult | null>(null);
|
||||
const [permissionScanLoading, setPermissionScanLoading] = useState(false);
|
||||
const [infoPanels, setInfoPanels] = useState<InfoPanel[]>([]);
|
||||
const [panelDraft, setPanelDraft] = useState<{ type: InfoPanelType; channelId: string; title: string; description: string; items: string }>({ type: 'rules', channelId: '', title: '', description: '', items: '' });
|
||||
const [ticketTopics, setTicketTopics] = useState<Record<string, TicketTopicConfig>>({});
|
||||
const [ticketTopicDraft, setTicketTopicDraft] = useState({ topic: '', roleId: '', questions: '' });
|
||||
|
||||
const setSection = useCallback((key: NavKey) => {
|
||||
setSectionState(key);
|
||||
@@ -204,7 +225,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash.replace('#', '') as NavKey;
|
||||
const validKeys: NavKey[] = ['overview', 'tickets', 'supportlogin', 'automod', 'welcome', 'dynamicvoice', 'birthday', 'reactionroles', 'statuspage', 'serverstats', 'register', 'music', 'settings', 'modules', 'events', 'admin'];
|
||||
const validKeys: NavKey[] = ['overview', 'tickets', 'supportlogin', 'automod', 'welcome', 'dynamicvoice', 'birthday', 'reactionroles', 'statuspage', 'serverstats', 'register', 'music', 'settings', 'modules', 'events', 'tasks', 'watchlist', 'branding', 'growth', 'permissions', 'panels', 'admin'];
|
||||
if (validKeys.includes(hash)) setSectionState(hash);
|
||||
}, []);
|
||||
|
||||
@@ -618,6 +639,64 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
setRecentJoins(res.recentJoins || []);
|
||||
}
|
||||
|
||||
async function loadPermissionScan() {
|
||||
if (!currentGuildId) return;
|
||||
setPermissionScanLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<any>(`/permissions/scan?guildId=${encodeURIComponent(currentGuildId)}`);
|
||||
setPermissionScan(res.result || null);
|
||||
} finally {
|
||||
setPermissionScanLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInfoPanels() {
|
||||
if (!currentGuildId) return;
|
||||
const res = await apiFetch<any>(`/panels?guildId=${encodeURIComponent(currentGuildId)}`);
|
||||
setInfoPanels(res.panels || []);
|
||||
}
|
||||
|
||||
async function createInfoPanel() {
|
||||
if (!currentGuildId || !panelDraft.channelId || !panelDraft.title) return;
|
||||
const items = panelDraft.type === 'faq'
|
||||
? panelDraft.items.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 25).map((line) => {
|
||||
const [q, ...rest] = line.split(':');
|
||||
return { question: (q || '').trim(), answer: rest.join(':').trim() || 'Keine Antwort hinterlegt.' };
|
||||
}).filter((i) => i.question)
|
||||
: undefined;
|
||||
await apiFetch('/panels', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ guildId: currentGuildId, channelId: panelDraft.channelId, type: panelDraft.type, title: panelDraft.title, description: panelDraft.description || undefined, items })
|
||||
});
|
||||
setPanelDraft({ type: 'rules', channelId: '', title: '', description: '', items: '' });
|
||||
setStatusMessage('Panel wurde gepostet');
|
||||
await loadInfoPanels();
|
||||
}
|
||||
|
||||
async function deleteInfoPanel(id: string) {
|
||||
await apiFetch(`/panels/${id}?guildId=${encodeURIComponent(currentGuildId)}`, { method: 'DELETE' });
|
||||
setStatusMessage('Panel gelöscht');
|
||||
await loadInfoPanels();
|
||||
}
|
||||
|
||||
async function loadTicketTopics() {
|
||||
if (!currentGuildId) return;
|
||||
const res = await apiFetch<any>(`/ticketconfig?guildId=${encodeURIComponent(currentGuildId)}`);
|
||||
setTicketTopics(res.topics || {});
|
||||
}
|
||||
|
||||
async function saveTicketTopic() {
|
||||
if (!currentGuildId || !ticketTopicDraft.topic.trim()) return;
|
||||
const questions = ticketTopicDraft.questions.split('\n').map((q) => q.trim()).filter(Boolean).slice(0, 5);
|
||||
await apiFetch('/ticketconfig', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ guildId: currentGuildId, topic: ticketTopicDraft.topic.trim().toLowerCase(), roleId: ticketTopicDraft.roleId || undefined, questions })
|
||||
});
|
||||
setTicketTopicDraft({ topic: '', roleId: '', questions: '' });
|
||||
setStatusMessage('Ticket-Kategorie gespeichert');
|
||||
await loadTicketTopics();
|
||||
}
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
window.location.href = `${appConfig.baseAuth || '/auth'}/logout`;
|
||||
}, []);
|
||||
@@ -633,7 +712,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
ticketDetail, ticketMessages, kbEditDraft, automationEditDraft,
|
||||
automodStrikes, registerStatusFilter, registerFormFilter, selectedAppId,
|
||||
appNotes, appHistory, noteDraft, tasks, taskDraft, watchlistEntries, growthStats,
|
||||
inviteBreakdown, recentJoins,
|
||||
inviteBreakdown, recentJoins, permissionScan, permissionScanLoading, infoPanels,
|
||||
panelDraft, ticketTopics, ticketTopicDraft,
|
||||
setCurrentGuildId, setSection, setSettings, setBirthday, setSupportLogin,
|
||||
setStatusDraft, setStatsDraft, setStatusMessage, loadGuildData,
|
||||
saveSettingsPayload, saveBirthday, saveStatuspage, saveServerStats,
|
||||
@@ -650,6 +730,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||
loadRegisterApps, openAppDetail, setNoteDraft, addAppNote,
|
||||
setTaskDraft, createTask, updateTaskStatus, deleteTask,
|
||||
loadWatchlist, removeFromWatchlist, loadGrowthStats, loadInviteBreakdown,
|
||||
loadPermissionScan, loadInfoPanels, setPanelDraft, createInfoPanel, deleteInfoPanel,
|
||||
loadTicketTopics, setTicketTopicDraft, saveTicketTopic,
|
||||
}}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
|
||||
128
frontend/src/pages/Panels.tsx
Normal file
128
frontend/src/pages/Panels.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Card, CardContent, CardHeader, Chip, Button, Input, TextArea, Separator, TextField, Label } from '@heroui/react';
|
||||
import { LayoutPanelTop, Trash2, Send, ScrollText, LifeBuoy, FileSignature, Handshake, Tags, CalendarDays, CircleHelp } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { useGuildResources } from '../hooks/useGuildResources';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
import { ChannelSelect } from '../components/shared/ChannelSelect';
|
||||
import { EmptyState } from '../components/shared/EmptyState';
|
||||
import type { InfoPanelType } from '../types';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const TYPE_META: Record<InfoPanelType, { label: string; icon: React.ReactNode }> = {
|
||||
rules: { label: 'Regeln', icon: <ScrollText size={14} /> },
|
||||
support: { label: 'Support', icon: <LifeBuoy size={14} /> },
|
||||
bewerbung: { label: 'Bewerbungen', icon: <FileSignature size={14} /> },
|
||||
partner: { label: 'Partner', icon: <Handshake size={14} /> },
|
||||
rollen: { label: 'Rollen', icon: <Tags size={14} /> },
|
||||
events: { label: 'Events', icon: <CalendarDays size={14} /> },
|
||||
faq: { label: 'FAQ', icon: <CircleHelp size={14} /> },
|
||||
};
|
||||
|
||||
export function Panels() {
|
||||
const { currentGuildId, infoPanels, loadInfoPanels, panelDraft, setPanelDraft, createInfoPanel, deleteInfoPanel } = useApp();
|
||||
const { channels } = useGuildResources(currentGuildId);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentGuildId) loadInfoPanels();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentGuildId]);
|
||||
|
||||
return (
|
||||
<SectionCard title="Info-Panels" subtitle="Feste Panels für Regeln, Support, Bewerbungen, Partner, Rollen, Events und FAQ mit passenden Buttons.">
|
||||
<div className="grid gap-5 xl:grid-cols-[1fr_420px]">
|
||||
<div>
|
||||
<h3 className="mb-3 text-base font-semibold">Bestehende Panels ({infoPanels.length})</h3>
|
||||
<div className="space-y-3">
|
||||
{infoPanels.length ? infoPanels.map((p) => (
|
||||
<Card key={p.id}>
|
||||
<CardContent className="flex items-center justify-between gap-3 p-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="bg-accent-soft text-accent-soft-foreground flex size-9 shrink-0 items-center justify-center rounded-lg">
|
||||
{TYPE_META[p.type]?.icon || <LayoutPanelTop size={14} />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">{p.title}</div>
|
||||
<div className="text-xs text-muted">#{channels.find((c) => c.id === p.channelId)?.name || p.channelId}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Chip size="sm" variant="soft">{TYPE_META[p.type]?.label || p.type}</Chip>
|
||||
<Button size="sm" variant="danger-soft" onPress={() => deleteInfoPanel(p.id)}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : <EmptyState message="Noch keine Panels erstellt" icon={<LayoutPanelTop size={24} />} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Neues Panel erstellen</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<TextField>
|
||||
<Label>Typ</Label>
|
||||
<select
|
||||
aria-label="Panel-Typ"
|
||||
className="w-full rounded-xl text-sm"
|
||||
value={panelDraft.type}
|
||||
onChange={(e) => setPanelDraft((s: any) => ({ ...s, type: e.target.value as InfoPanelType }))}
|
||||
>
|
||||
{Object.entries(TYPE_META).map(([key, meta]) => (
|
||||
<option key={key} value={key}>{meta.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
<Label>Ziel-Kanal</Label>
|
||||
<ChannelSelect options={channels} value={panelDraft.channelId} onChange={(id) => setPanelDraft((s: any) => ({ ...s, channelId: id }))} />
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
<Label>Titel</Label>
|
||||
<Input value={panelDraft.title} onChange={(e) => setPanelDraft((s: any) => ({ ...s, title: e.target.value }))} />
|
||||
</TextField>
|
||||
|
||||
{panelDraft.type !== 'faq' && (
|
||||
<TextField>
|
||||
<Label>Beschreibung</Label>
|
||||
<TextArea rows={4} value={panelDraft.description} onChange={(e) => setPanelDraft((s: any) => ({ ...s, description: e.target.value }))} />
|
||||
</TextField>
|
||||
)}
|
||||
|
||||
{panelDraft.type === 'faq' && (
|
||||
<TextField>
|
||||
<Label>Fragen (eine „Frage: Antwort“ pro Zeile)</Label>
|
||||
<TextArea
|
||||
rows={5}
|
||||
placeholder="Wie erstelle ich ein Ticket?: Klicke auf den Support-Button."
|
||||
value={panelDraft.items}
|
||||
onChange={(e) => setPanelDraft((s: any) => ({ ...s, items: e.target.value }))}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<p className="text-xs text-muted">
|
||||
{panelDraft.type === 'support' && 'Erhält automatisch einen Ticket-Button.'}
|
||||
{panelDraft.type === 'bewerbung' && 'Erhält automatisch einen Bewerben-Button, falls ein aktives Formular existiert.'}
|
||||
{panelDraft.type === 'partner' && 'Erhält automatisch einen Partner-werden-Button.'}
|
||||
{panelDraft.type === 'faq' && 'Erhält ein Dropdown mit den hinterlegten Fragen.'}
|
||||
{['rules', 'rollen', 'events'].includes(panelDraft.type) && 'Reines Info-Embed ohne Button.'}
|
||||
</p>
|
||||
|
||||
<Button variant="primary" onPress={createInfoPanel} isDisabled={!panelDraft.channelId || !panelDraft.title}>
|
||||
<Send size={16} /> Panel posten
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
125
frontend/src/pages/Permissions.tsx
Normal file
125
frontend/src/pages/Permissions.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, Chip, Button } from '@heroui/react';
|
||||
import { ShieldAlert, ShieldX, Bot, Globe, MessageSquare, Users, Copy, Ban, RefreshCw, TriangleAlert } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
import { StatCard } from '../components/shared/StatCard';
|
||||
import { EmptyState } from '../components/shared/EmptyState';
|
||||
|
||||
function EntryList({ entries, emptyMessage, icon }: { entries: { id: string; name: string }[]; emptyMessage: string; icon: React.ReactNode }) {
|
||||
if (!entries.length) return <p className="py-3 text-center text-xs text-muted">{emptyMessage}</p>;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{entries.slice(0, 10).map((e) => (
|
||||
<div key={e.id} className="bg-surface-tertiary flex items-center gap-2 rounded-lg px-3 py-2 text-sm">
|
||||
{icon}
|
||||
<span className="truncate">{e.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{entries.length > 10 && <p className="pl-1 text-xs text-muted">+{entries.length - 10} weitere</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Permissions() {
|
||||
const { currentGuildId, permissionScan, permissionScanLoading, loadPermissionScan } = useApp();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentGuildId) loadPermissionScan();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentGuildId]);
|
||||
|
||||
const r = permissionScan;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Rechte-Scanner"
|
||||
subtitle="Sicherheitsanalyse der Server-Rollen, Rechte und Kanäle."
|
||||
action={
|
||||
<Button variant="primary" onPress={() => loadPermissionScan()} isDisabled={permissionScanLoading}>
|
||||
<RefreshCw size={16} className={permissionScanLoading ? 'animate-spin' : ''} /> Scan starten
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{!r && !permissionScanLoading && (
|
||||
<EmptyState message="Noch kein Scan durchgeführt. Klicke auf „Scan starten“." icon={<ShieldAlert size={24} />} />
|
||||
)}
|
||||
|
||||
{r && (
|
||||
<div className="flex flex-col gap-5">
|
||||
{r.tooManyAdmins && (
|
||||
<Card className="border-danger bg-danger-soft">
|
||||
<CardContent className="flex items-center gap-3 p-4 text-sm">
|
||||
<TriangleAlert size={18} className="shrink-0 text-danger" />
|
||||
<span>Auffällig viele Administrator-Rechte vergeben ({r.adminRoles.length} Rollen, {r.adminMemberCount} Mitglieder mit Adminrechten). Prüfe, ob das notwendig ist.</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard icon={<ShieldAlert size={18} />} label="Admin-Rollen" value={r.adminRoles.length} color={r.tooManyAdmins ? 'danger' : 'default'} />
|
||||
<StatCard icon={<Ban size={18} />} label="Ban-Rollen" value={r.banRoles.length} />
|
||||
<StatCard icon={<Bot size={18} />} label="Bots mit gefährlichen Rechten" value={r.dangerousBots.length} color={r.dangerousBots.length ? 'warning' : 'default'} />
|
||||
<StatCard icon={<Globe size={18} />} label="Öffentliche Kanäle" value={r.publicChannels.length} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Admin-Rollen</h3></CardHeader>
|
||||
<CardContent className="p-5"><EntryList entries={r.adminRoles} emptyMessage="Keine Admin-Rollen" icon={<ShieldAlert size={14} className="text-danger shrink-0" />} /></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Rollen, die bannen können</h3></CardHeader>
|
||||
<CardContent className="p-5"><EntryList entries={r.banRoles} emptyMessage="Keine Ban-Rollen" icon={<Ban size={14} className="text-warning shrink-0" />} /></CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Bots mit gefährlichen Rechten</h3></CardHeader>
|
||||
<CardContent className="p-5">
|
||||
{r.dangerousBots.length ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{r.dangerousBots.slice(0, 10).map((b) => (
|
||||
<div key={b.id} className="bg-surface-tertiary flex flex-col gap-1 rounded-lg px-3 py-2 text-sm">
|
||||
<div className="flex items-center gap-2"><Bot size={14} className="text-muted shrink-0" /><span className="truncate font-medium">{b.tag}</span></div>
|
||||
<div className="flex flex-wrap gap-1 pl-5">
|
||||
{b.perms.map((p) => <Chip key={p} size="sm" variant="soft" color="warning">{p}</Chip>)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="py-3 text-center text-xs text-muted">Keine Bots mit gefährlichen Rechten</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">@everyone kann schreiben</h3></CardHeader>
|
||||
<CardContent className="p-5"><EntryList entries={r.everyoneCanSendChannels} emptyMessage="Kein Kanal betroffen" icon={<MessageSquare size={14} className="text-muted shrink-0" />} /></CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Leere Rollen</h3></CardHeader>
|
||||
<CardContent className="p-5"><EntryList entries={r.emptyRoles} emptyMessage="Keine leeren Rollen" icon={<Users size={14} className="text-muted shrink-0" />} /></CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Nutzlose Rollen</h3></CardHeader>
|
||||
<CardContent className="p-5"><EntryList entries={r.uselessRoles} emptyMessage="Keine nutzlosen Rollen" icon={<ShieldX size={14} className="text-muted shrink-0" />} /></CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="px-5 pt-5 pb-0"><h3 className="text-base font-semibold">Doppelte Rollen (identische Rechte)</h3></CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 p-5">
|
||||
{r.duplicateRoleGroups.length ? r.duplicateRoleGroups.map((group, i) => (
|
||||
<div key={i} className="bg-surface-tertiary flex flex-wrap items-center gap-2 rounded-lg px-3 py-2 text-sm">
|
||||
<Copy size={14} className="text-muted shrink-0" />
|
||||
{group.map((role) => <Chip key={role.id} size="sm" variant="soft">{role.name}</Chip>)}
|
||||
</div>
|
||||
)) : <p className="py-3 text-center text-xs text-muted">Keine doppelten Rollen gefunden</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,31 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, Chip, Button, Tabs, Tab, Input, TextArea, Separator, TextField, Label } from '@heroui/react';
|
||||
import { Ticket, Clock, UserRound, CheckCircle, MessageSquare, FileText, Pencil, Trash2, ChevronRight } from 'lucide-react';
|
||||
import { Ticket, Clock, UserRound, CheckCircle, MessageSquare, FileText, Pencil, Trash2, ChevronRight, Tag, Users } from 'lucide-react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
import { SectionCard } from '../components/shared/SectionCard';
|
||||
import { StatCard } from '../components/shared/StatCard';
|
||||
import { EmptyState } from '../components/shared/EmptyState';
|
||||
import { TicketKanban } from '../components/shared/TicketKanban';
|
||||
import { RoleSelect } from '../components/shared/RoleSelect';
|
||||
import { useGuildResources } from '../hooks/useGuildResources';
|
||||
|
||||
export function Tickets() {
|
||||
const {
|
||||
tickets, pipeline, sla, automations, kbArticles, ticketTab, setTicketTab,
|
||||
currentGuildId, tickets, pipeline, sla, automations, kbArticles, ticketTab, setTicketTab,
|
||||
ticketDetail, setTicketDetail, ticketMessages, loadTicketMessages,
|
||||
updateTicketStatus, closeTicket, automationDraft, setAutomationDraft,
|
||||
saveAutomation, kbDraft, setKbDraft, saveKbArticle, kbEditDraft, setKbEditDraft,
|
||||
updateKbArticle, deleteKbArticle, automationEditDraft, setAutomationEditDraft,
|
||||
updateAutomation, deleteAutomation, overview
|
||||
updateAutomation, deleteAutomation, overview,
|
||||
ticketTopics, loadTicketTopics, ticketTopicDraft, setTicketTopicDraft, saveTicketTopic
|
||||
} = useApp();
|
||||
const { roles } = useGuildResources(currentGuildId);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentGuildId) loadTicketTopics();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentGuildId]);
|
||||
|
||||
const openTickets = useMemo(() => tickets.filter((t) => t.status !== 'closed'), [tickets]);
|
||||
|
||||
@@ -30,6 +39,7 @@ export function Tickets() {
|
||||
<Tab id="sla">SLA</Tab>
|
||||
<Tab id="automations">Automationen</Tab>
|
||||
<Tab id="kb">Knowledge Base</Tab>
|
||||
<Tab id="kategorien">Kategorien</Tab>
|
||||
</Tabs.List>
|
||||
</Tabs.ListContainer>
|
||||
</Tabs>
|
||||
@@ -350,6 +360,85 @@ export function Tickets() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticketTab === 'kategorien' && (
|
||||
<div className="mt-5 grid gap-5 xl:grid-cols-[1fr_420px]">
|
||||
<div>
|
||||
<h3 className="mb-3 text-base font-semibold">Konfigurierte Kategorien ({Object.keys(ticketTopics).length})</h3>
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
Der Kategorie-Slug entspricht dem Ticket-Grund (z.B. <code>ban</code>, <code>help</code>, <code>feedback</code>, <code>other</code>). Ohne Konfiguration bleibt das bisherige Verhalten unverändert.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{Object.keys(ticketTopics).length ? Object.entries(ticketTopics).map(([topic, cfg]) => (
|
||||
<Card key={topic}>
|
||||
<CardContent className="flex items-center justify-between gap-3 p-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="bg-accent-soft text-accent-soft-foreground flex size-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<Tag size={14} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">{topic}</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted">
|
||||
<Users size={12} />
|
||||
{cfg.roleId ? roles.find((r) => r.id === cfg.roleId)?.name || cfg.roleId : 'Standard-Support-Rolle'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Chip size="sm" variant="soft">{cfg.questions?.length ?? 0} Fragen</Chip>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onPress={() => setTicketTopicDraft({ topic, roleId: cfg.roleId || '', questions: (cfg.questions || []).join('\n') })}
|
||||
>
|
||||
<Pencil size={14} /> Bearbeiten
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)) : <EmptyState message="Noch keine Kategorien konfiguriert" icon={<Tag size={24} />} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<h3 className="text-base font-semibold">Kategorie {ticketTopicDraft.topic ? 'bearbeiten' : 'anlegen'}</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 p-5">
|
||||
<TextField>
|
||||
<Label>Kategorie-Slug</Label>
|
||||
<Input
|
||||
placeholder="z.B. help, ban, feedback, other"
|
||||
value={ticketTopicDraft.topic}
|
||||
onChange={(e) => setTicketTopicDraft((s: any) => ({ ...s, topic: e.target.value }))}
|
||||
/>
|
||||
</TextField>
|
||||
<TextField>
|
||||
<Label>Rolle, die gepingt wird</Label>
|
||||
<RoleSelect options={roles} value={ticketTopicDraft.roleId} onChange={(id) => setTicketTopicDraft((s: any) => ({ ...s, roleId: id }))} placeholder="Standard-Support-Rolle" />
|
||||
</TextField>
|
||||
<TextField>
|
||||
<Label>Fragen-Vorlage (eine pro Zeile, max. 5)</Label>
|
||||
<TextArea
|
||||
rows={5}
|
||||
placeholder={'Was ist passiert?\nWann ist es aufgetreten?\nHast du einen Screenshot?'}
|
||||
value={ticketTopicDraft.questions}
|
||||
onChange={(e) => setTicketTopicDraft((s: any) => ({ ...s, questions: e.target.value }))}
|
||||
/>
|
||||
</TextField>
|
||||
<p className="text-xs text-muted">Wenn Fragen hinterlegt sind, öffnet sich beim Ticket-Erstellen ein Formular statt sofort ein Kanal zu erstellen.</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="primary" onPress={saveTicketTopic} isDisabled={!ticketTopicDraft.topic.trim()}>Speichern</Button>
|
||||
{ticketTopicDraft.topic && (
|
||||
<Button variant="tertiary" onPress={() => setTicketTopicDraft({ topic: '', roleId: '', questions: '' })}>Abbrechen</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ export type NavKey =
|
||||
| 'watchlist'
|
||||
| 'branding'
|
||||
| 'growth'
|
||||
| 'permissions'
|
||||
| 'panels'
|
||||
| 'admin';
|
||||
|
||||
export type TicketRecord = {
|
||||
@@ -196,6 +198,38 @@ export type RecentJoinEntry = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type PermissionScanResult = {
|
||||
adminRoles: { id: string; name: string }[];
|
||||
banRoles: { id: string; name: string }[];
|
||||
dangerousBots: { id: string; tag: string; perms: string[] }[];
|
||||
publicChannels: { id: string; name: string }[];
|
||||
everyoneCanSendChannels: { id: string; name: string }[];
|
||||
emptyRoles: { id: string; name: string }[];
|
||||
duplicateRoleGroups: { id: string; name: string }[][];
|
||||
uselessRoles: { id: string; name: string }[];
|
||||
tooManyAdmins: boolean;
|
||||
adminMemberCount: number;
|
||||
};
|
||||
|
||||
export type InfoPanelType = 'rules' | 'support' | 'bewerbung' | 'partner' | 'rollen' | 'events' | 'faq';
|
||||
|
||||
export type InfoPanel = {
|
||||
id: string;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
messageId?: string | null;
|
||||
type: InfoPanelType;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
items?: { question: string; answer: string }[] | null;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type TicketTopicConfig = {
|
||||
roleId?: string;
|
||||
questions?: string[];
|
||||
};
|
||||
|
||||
export type MusicSession = {
|
||||
guildId: string;
|
||||
nowPlaying?: { title: string; url: string } | null;
|
||||
|
||||
Reference in New Issue
Block a user