improve dashboard usability and fix backend TS build errors
Some checks failed
Deploy Discord Bot / deploy (push) Failing after -1m3s
SonarQube / sonar (push) Successful in 1m17s

Frontend: fix HeroUI Switch usage across the whole app (Content/Control/
Thumb composition was missing, so no toggle ever rendered visibly),
replace raw channel/role ID text fields with proper name-based dropdowns
(ChannelSelect/RoleSelect) backed by the existing /guild/resources
endpoint, redesign Automod's filters as inline toggle rows, turn the
Ticket Pipeline tab into a drag-and-drop Kanban board, narrow the
sidebar further and rework its collapse toggle and profile footer, and
add a Discord-style message preview for Welcome/Support Login.

Backend: fix the ~50 TypeScript build errors blocking `npm run build`
(SlashCommandBuilder typing, discord.js v14 channel-union guards,
Prisma JSON null handling, logger.warn signature, and related type
narrowing) so the project compiles cleanly again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Pepe44DEV
2026-07-02 13:55:17 +02:00
parent 0c47dce508
commit 3c31832a0d
35 changed files with 691 additions and 310 deletions

View File

@@ -0,0 +1,32 @@
import { Switch } from '@heroui/react';
import type { ReactNode } from 'react';
type Props = {
isSelected: boolean;
onChange: (value: boolean) => void;
label?: ReactNode;
description?: ReactNode;
isDisabled?: boolean;
size?: 'sm' | 'md' | 'lg';
'aria-label'?: string;
};
export function AppSwitch({ isSelected, onChange, label, description, isDisabled, size, ...rest }: Props) {
return (
<Switch
isSelected={isSelected}
onChange={onChange}
isDisabled={isDisabled}
size={size}
aria-label={!label ? rest['aria-label'] || 'Umschalten' : undefined}
>
<Switch.Content>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
{label && <span>{label}</span>}
</Switch.Content>
{description && <p data-slot="description" className="text-xs text-muted">{description}</p>}
</Switch>
);
}

View File

@@ -0,0 +1,56 @@
import { Select, SelectTrigger, SelectValue, SelectPopover, ListBox, ListBoxItem } from '@heroui/react';
import { Hash, Volume2, Folder, ChevronDown } from 'lucide-react';
export type ChannelOption = { id: string; name: string; type?: string };
function optionIcon(type?: string) {
if (type === 'voice') return <Volume2 size={14} className="shrink-0 text-muted" />;
if (type === 'category') return <Folder size={14} className="shrink-0 text-muted" />;
return <Hash size={14} className="shrink-0 text-muted" />;
}
type Props = {
options: ChannelOption[];
value?: string;
onChange: (id: string) => void;
placeholder?: string;
'aria-label'?: string;
};
export function ChannelSelect({ options, value, onChange, placeholder = 'Channel wählen', ...rest }: Props) {
const items = value && !options.some((o) => o.id === value)
? [...options, { id: value, name: value, type: 'unknown' }]
: options;
return (
<Select
aria-label={rest['aria-label'] || placeholder}
placeholder={placeholder}
selectedKey={value || null}
onSelectionChange={(key) => { if (typeof key === 'string') onChange(key); }}
>
<SelectTrigger className="w-full justify-between">
<span className="flex min-w-0 flex-1 items-center gap-2">
{optionIcon(items.find((o) => o.id === value)?.type)}
<SelectValue className="truncate" />
</span>
<ChevronDown size={14} className="shrink-0 text-muted" />
</SelectTrigger>
<SelectPopover className="max-h-72 w-[--trigger-width]">
<ListBox>
{items.length ? items.map((o) => (
<ListBoxItem key={o.id} id={o.id} textValue={o.name}>
<span className="flex items-center gap-2">
{optionIcon(o.type)} {o.type === 'unknown' ? `Unbekannt (${o.name})` : o.name}
</span>
</ListBoxItem>
)) : (
<ListBoxItem key="__empty" id="__empty" isDisabled textValue="Keine Channels gefunden">
Keine Channels gefunden
</ListBoxItem>
)}
</ListBox>
</SelectPopover>
</Select>
);
}

View File

@@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import { Bot } from 'lucide-react';
type Props = {
botName?: string;
title?: string;
description?: string;
footer?: string;
accentColor?: string;
children?: ReactNode;
};
const now = () => new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
export function DiscordPreview({ botName = 'Papo', title, description, footer, accentColor = '#5865f2', children }: Props) {
return (
<div className="rounded-lg bg-[#313338] p-4">
<div className="flex gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-[#5865f2] text-white">
<Bot size={20} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-2">
<span className="text-[15px] font-medium text-white">{botName}</span>
<span className="rounded bg-[#5865f2] px-1 py-px text-[10px] font-medium leading-none text-white">BOT</span>
<span className="text-xs text-[#949ba4]">Heute um {now()}</span>
</div>
{(title || description || footer) && (
<div
className="mt-1 max-w-md rounded border-l-4 bg-[#2b2d31] p-3"
style={{ borderLeftColor: accentColor }}
>
{title && <div className="text-[15px] font-semibold text-white">{title}</div>}
{description && (
<div className="mt-1 whitespace-pre-wrap text-sm leading-snug text-[#dbdee1]">{description}</div>
)}
{footer && <div className="mt-2.5 text-xs text-[#949ba4]">{footer}</div>}
</div>
)}
{children && <div className="mt-2 flex flex-wrap gap-2">{children}</div>}
</div>
</div>
</div>
);
}
export function DiscordButton({ variant = 'primary', children }: { variant?: 'primary' | 'secondary'; children: ReactNode }) {
return (
<div
className={`flex h-8 items-center gap-1.5 rounded px-3 text-sm font-medium text-white ${
variant === 'primary' ? 'bg-[#5865f2]' : 'bg-[#4e5058]'
}`}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { Select, SelectTrigger, SelectValue, SelectPopover, ListBox, ListBoxItem } from '@heroui/react';
import { ChevronDown, AtSign } from 'lucide-react';
export type RoleOption = { id: string; name: string; color?: string };
type Props = {
options: RoleOption[];
value?: string;
onChange: (id: string) => void;
placeholder?: string;
'aria-label'?: string;
};
export function RoleSelect({ options, value, onChange, placeholder = 'Rolle wählen', ...rest }: Props) {
const items = value && !options.some((o) => o.id === value)
? [...options, { id: value, name: value }]
: options;
const selected = items.find((o) => o.id === value);
return (
<Select
aria-label={rest['aria-label'] || placeholder}
placeholder={placeholder}
selectedKey={value || null}
onSelectionChange={(key) => { if (typeof key === 'string') onChange(key); }}
>
<SelectTrigger className="w-full justify-between">
<span className="flex min-w-0 flex-1 items-center gap-2">
{selected?.color ? (
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: selected.color }} />
) : (
<AtSign size={14} className="shrink-0 text-muted" />
)}
<SelectValue className="truncate" />
</span>
<ChevronDown size={14} className="shrink-0 text-muted" />
</SelectTrigger>
<SelectPopover className="max-h-72 w-[--trigger-width]">
<ListBox>
{items.length ? items.map((o) => (
<ListBoxItem key={o.id} id={o.id} textValue={o.name}>
<span className="flex items-center gap-2">
{o.color ? <span className="size-2.5 rounded-full" style={{ backgroundColor: o.color }} /> : <AtSign size={14} className="text-muted" />}
{o.name}
</span>
</ListBoxItem>
)) : (
<ListBoxItem key="__empty" id="__empty" isDisabled textValue="Keine Rollen gefunden">
Keine Rollen gefunden
</ListBoxItem>
)}
</ListBox>
</SelectPopover>
</Select>
);
}

View File

@@ -0,0 +1,98 @@
import { useState } from 'react';
import { Card, CardHeader, CardContent, Chip } from '@heroui/react';
import { GripVertical } from 'lucide-react';
import { formatDate } from '../../utils/formatters';
type TicketRecord = {
id: string;
topic?: string;
createdAt?: string | number;
category?: string;
[key: string]: unknown;
};
type Column = { key: string; label: string; color: 'warning' | 'accent' | 'default' | 'success' };
const COLUMNS: Column[] = [
{ key: 'neu', label: 'Neu', color: 'warning' },
{ key: 'in_bearbeitung', label: 'In Bearbeitung', color: 'accent' },
{ key: 'warten_auf_user', label: 'Warten auf User', color: 'default' },
{ key: 'erledigt', label: 'Erledigt', color: 'success' },
];
type Props = {
pipeline: Record<string, TicketRecord[]>;
updateTicketStatus: (ticketId: string, status: string) => Promise<void>;
};
export function TicketKanban({ pipeline, updateTicketStatus }: Props) {
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverKey, setDragOverKey] = useState<string | null>(null);
return (
<div className="mt-5 grid gap-4 lg:grid-cols-2 2xl:grid-cols-4">
{COLUMNS.map(({ key, label, color }) => {
const items = pipeline[key] || [];
const isDropTarget = dragOverKey === key;
return (
<Card
key={key}
className={`transition-colors ${isDropTarget ? 'border-accent' : 'border-transparent'} border-2`}
onDragOver={(e) => {
e.preventDefault();
if (dragOverKey !== key) setDragOverKey(key);
}}
onDragLeave={() => setDragOverKey((k) => (k === key ? null : k))}
onDrop={(e) => {
e.preventDefault();
const id = e.dataTransfer.getData('text/plain') || draggedId;
if (id) updateTicketStatus(id, key);
setDragOverKey(null);
setDraggedId(null);
}}
>
<CardHeader className="px-4 pt-4 pb-0">
<div className="flex items-center gap-2">
<div className={`size-2.5 rounded-full bg-${color}`} />
<h3 className="text-sm font-semibold">{label}</h3>
<Chip size="sm" variant="soft" color={color}>{items.length}</Chip>
</div>
</CardHeader>
<CardContent className="flex min-h-[120px] flex-col gap-2 p-4">
{items.length ? items.map((t) => (
<div
key={t.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', t.id);
e.dataTransfer.effectAllowed = 'move';
setDraggedId(t.id);
}}
onDragEnd={() => {
setDraggedId(null);
setDragOverKey(null);
}}
className={`bg-surface-tertiary flex cursor-grab items-start gap-2 rounded-xl p-3 text-sm active:cursor-grabbing ${
draggedId === t.id ? 'opacity-40' : ''
}`}
>
<GripVertical size={14} className="mt-0.5 shrink-0 text-muted" />
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{t.topic || t.id}</div>
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted">
{t.category && <span>{t.category}</span>}
{t.category && <span>·</span>}
<span>{formatDate(t.createdAt)}</span>
</div>
</div>
</div>
)) : (
<p className="py-4 text-center text-xs text-muted">Tickets hierher ziehen</p>
)}
</CardContent>
</Card>
);
})}
</div>
);
}