improve dashboard usability and fix backend TS build errors
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:
32
frontend/src/components/shared/AppSwitch.tsx
Normal file
32
frontend/src/components/shared/AppSwitch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/shared/ChannelSelect.tsx
Normal file
56
frontend/src/components/shared/ChannelSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
frontend/src/components/shared/DiscordPreview.tsx
Normal file
59
frontend/src/components/shared/DiscordPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/shared/RoleSelect.tsx
Normal file
56
frontend/src/components/shared/RoleSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
frontend/src/components/shared/TicketKanban.tsx
Normal file
98
frontend/src/components/shared/TicketKanban.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user