Automod: per-filter actions (warn/timeout/kick/ban), channel/role exemptions, regex/severity badword rules, and a strike/escalation system backed by a new AutomodStrike table. Register: fixes missing answer labels, adds internal reviewer notes and cross-form application history for a user, and exposes form/status filters in the dashboard. Dashboard: overrides HeroUI's default blue accent token with the bot's established orange (#f97316), which was lost during the HeroUI v3 migration. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
242 lines
12 KiB
TypeScript
242 lines
12 KiB
TypeScript
import { Card, CardContent, CardHeader, Input, TextArea, Button, Chip, Tabs, Tab, Separator, TextField, Label } from '@heroui/react';
|
|
import { ClipboardList, Pencil, Trash2, Send, Plus, FileText, History, MessageSquare } from 'lucide-react';
|
|
import { useApp } from '../context/AppContext';
|
|
import { SectionCard } from '../components/shared/SectionCard';
|
|
import { formatDate } from '../utils/formatters';
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
pending: 'Ausstehend',
|
|
accepted: 'Akzeptiert',
|
|
invited: 'Zum Gespräch eingeladen',
|
|
rejected: 'Abgelehnt',
|
|
};
|
|
|
|
const STATUS_COLORS: Record<string, 'success' | 'danger' | 'warning' | 'accent' | 'default'> = {
|
|
pending: 'warning',
|
|
accepted: 'success',
|
|
invited: 'accent',
|
|
rejected: 'danger',
|
|
};
|
|
|
|
export function Register() {
|
|
const {
|
|
registerForms, registerApps, registerTab, setRegisterTab,
|
|
formDraft, setFormDraft, editingFormId, setEditingFormId,
|
|
saveForm, deleteForm, sendFormPanel,
|
|
registerStatusFilter, registerFormFilter, setRegisterStatusFilter, setRegisterFormFilter, loadRegisterApps,
|
|
selectedAppId, openAppDetail, appHistory, appNotes, noteDraft, setNoteDraft, addAppNote
|
|
} = useApp();
|
|
|
|
return (
|
|
<SectionCard title="Registrierungsformulare" subtitle="Bewerbungs-Formulare und eingegangene Anträge verwalten.">
|
|
<Tabs selectedKey={registerTab} variant="primary" onSelectionChange={(key) => setRegisterTab(String(key))}>
|
|
<Tabs.ListContainer>
|
|
<Tabs.List aria-label="Register Tabs">
|
|
<Tab id="forms">Formulare</Tab>
|
|
<Tab id="apps">Anträge</Tab>
|
|
</Tabs.List>
|
|
</Tabs.ListContainer>
|
|
</Tabs>
|
|
|
|
{registerTab === 'forms' && (
|
|
<div className="mt-5 grid gap-5 xl:grid-cols-[1fr_420px]">
|
|
<div>
|
|
<h3 className="mb-3 text-base font-semibold">Bestehende Formulare ({registerForms.length})</h3>
|
|
<div className="space-y-3">
|
|
{registerForms.length ? registerForms.map((f) => (
|
|
<Card key={f.id}>
|
|
<CardContent className="flex flex-col gap-3 p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<FileText size={16} className="text-accent shrink-0" />
|
|
<span className="font-semibold text-sm truncate">{f.name}</span>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<Button isIconOnly size="sm" variant="ghost" onPress={() => {
|
|
setFormDraft({
|
|
name: f.name, description: f.description || '',
|
|
reviewChannelId: f.reviewChannelId || '',
|
|
notifyRoleIds: (f.notifyRoleIds || []).join(', '),
|
|
fields: (f.fields || []).map((fd) =>
|
|
fd.label + '|' + fd.type + (fd.required ? '|required' : '') + (fd.options ? '|' + fd.options.join(',') : '')
|
|
).join('\n')
|
|
});
|
|
setEditingFormId(f.id);
|
|
}}>
|
|
<Pencil size={14} />
|
|
</Button>
|
|
<Button isIconOnly size="sm" variant="danger-soft" onPress={() => deleteForm(f.id)}>
|
|
<Trash2 size={14} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-muted">{f.description || 'Keine Beschreibung'}</p>
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<Chip size="sm" variant="soft" color={f.isActive ? 'success' : 'default'}>
|
|
{f.isActive ? 'Aktiv' : 'Inaktiv'}
|
|
</Chip>
|
|
<span className="text-muted">{f.fields?.length || 0} Felder</span>
|
|
<Button size="sm" variant="tertiary" onPress={() => sendFormPanel(f.id)}>
|
|
<Send size={12} /> Panel senden
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)) : (
|
|
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
|
|
<ClipboardList size={24} />
|
|
Keine Formulare
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="px-5 pt-5 pb-0">
|
|
<h3 className="text-base font-semibold">{editingFormId ? 'Formular bearbeiten' : 'Neues Formular'}</h3>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4 p-5">
|
|
<TextField>
|
|
<Label>Name</Label>
|
|
<Input value={formDraft.name} onChange={(e) => setFormDraft((s) => ({ ...s, name: e.target.value }))} />
|
|
</TextField>
|
|
<TextField>
|
|
<Label>Beschreibung</Label>
|
|
<Input value={formDraft.description} onChange={(e) => setFormDraft((s) => ({ ...s, description: e.target.value }))} />
|
|
</TextField>
|
|
<TextField>
|
|
<Label>Review Channel ID</Label>
|
|
<Input value={formDraft.reviewChannelId} onChange={(e) => setFormDraft((s) => ({ ...s, reviewChannelId: e.target.value }))} />
|
|
</TextField>
|
|
<TextField>
|
|
<Label>Benachrichtigungs-Rollen (Komma-getrennt)</Label>
|
|
<Input value={formDraft.notifyRoleIds} onChange={(e) => setFormDraft((s) => ({ ...s, notifyRoleIds: e.target.value }))} />
|
|
</TextField>
|
|
<TextField>
|
|
<Label>Felder (label|type|required|options)</Label>
|
|
<TextArea rows={6} value={formDraft.fields} onChange={(e) => setFormDraft((s) => ({ ...s, fields: e.target.value }))} />
|
|
</TextField>
|
|
<p className="text-xs text-muted">
|
|
Pro Zeile: label | type (text/paragraph/select/multi) | required | option1,option2
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button variant="primary" onPress={saveForm}>{editingFormId ? 'Aktualisieren' : 'Erstellen'}</Button>
|
|
{editingFormId && (
|
|
<Button variant="tertiary" onPress={() => { setEditingFormId(null); setFormDraft({ name: '', description: '', reviewChannelId: '', notifyRoleIds: '', fields: '' }); }}>
|
|
Abbrechen
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{registerTab === 'apps' && (
|
|
<div className="mt-5">
|
|
<div className="mb-4 flex flex-wrap items-end gap-2">
|
|
<TextField className="w-56">
|
|
<Label className="text-xs">Formular/Position</Label>
|
|
<select
|
|
className="w-full rounded-xl px-3 py-2 text-sm"
|
|
value={registerFormFilter}
|
|
onChange={(e) => { setRegisterFormFilter(e.target.value); loadRegisterApps({ formId: e.target.value }); }}
|
|
>
|
|
<option value="">Alle</option>
|
|
{registerForms.map((f) => <option key={f.id} value={f.id}>{f.name}</option>)}
|
|
</select>
|
|
</TextField>
|
|
<TextField className="w-48">
|
|
<Label className="text-xs">Status</Label>
|
|
<select
|
|
className="w-full rounded-xl px-3 py-2 text-sm"
|
|
value={registerStatusFilter}
|
|
onChange={(e) => { setRegisterStatusFilter(e.target.value); loadRegisterApps({ status: e.target.value }); }}
|
|
>
|
|
<option value="">Alle</option>
|
|
{Object.entries(STATUS_LABELS).map(([value, label]) => (
|
|
<option key={value} value={value}>{label}</option>
|
|
))}
|
|
</select>
|
|
</TextField>
|
|
</div>
|
|
|
|
<h3 className="mb-3 text-base font-semibold">Eingegangene Anträge ({registerApps.length})</h3>
|
|
<div className="space-y-3">
|
|
{registerApps.length ? registerApps.map((app) => (
|
|
<Card key={app.id}>
|
|
<CardContent className="flex flex-col gap-3 p-4">
|
|
<button type="button" className="flex items-center justify-between gap-2 text-left" onClick={() => openAppDetail(app.id)}>
|
|
<div className="min-w-0">
|
|
<div className="font-semibold truncate">{app.username || app.userId}</div>
|
|
<div className="text-xs text-muted truncate">{app.form?.name || 'Formular'}</div>
|
|
</div>
|
|
<Chip size="sm" variant="soft" color={STATUS_COLORS[app.status] || 'default'}>
|
|
{STATUS_LABELS[app.status] || app.status}
|
|
</Chip>
|
|
</button>
|
|
<div className="text-xs text-muted">{formatDate(app.createdAt)}</div>
|
|
{app.answers?.length ? (
|
|
<div className="space-y-1">
|
|
{app.answers.map((a, i) => (
|
|
<div key={i} className="text-sm">
|
|
<span className="text-muted">{a.label || 'Frage'}: </span>
|
|
{a.value}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{selectedAppId === app.id && (
|
|
<div className="mt-2 grid gap-4 border-t border-border pt-3 sm:grid-cols-2">
|
|
<div>
|
|
<h4 className="mb-2 flex items-center gap-1.5 text-sm font-semibold"><History size={14} /> Bisherige Bewerbungen</h4>
|
|
{appHistory.length ? (
|
|
<div className="space-y-1.5">
|
|
{appHistory.map((h) => (
|
|
<div key={h.id} className="flex items-center justify-between gap-2 text-xs">
|
|
<span className="truncate">{h.form?.name || 'Formular'} · {formatDate(h.createdAt)}</span>
|
|
<Chip size="sm" variant="soft" color={STATUS_COLORS[h.status] || 'default'}>{STATUS_LABELS[h.status] || h.status}</Chip>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : <p className="text-xs text-muted">Keine weiteren Bewerbungen dieses Nutzers</p>}
|
|
</div>
|
|
<div>
|
|
<h4 className="mb-2 flex items-center gap-1.5 text-sm font-semibold"><MessageSquare size={14} /> Interne Notizen</h4>
|
|
<div className="space-y-2">
|
|
{appNotes.length ? appNotes.map((n) => (
|
|
<div key={n.id} className="rounded-lg bg-surface-secondary p-2 text-xs">
|
|
<div className="mb-1 flex items-center justify-between text-muted">
|
|
<span className="font-medium text-default">{n.authorTag}</span>
|
|
<span>{formatDate(n.createdAt)}</span>
|
|
</div>
|
|
{n.body}
|
|
</div>
|
|
)) : <p className="text-xs text-muted">Keine Notizen</p>}
|
|
</div>
|
|
<TextArea
|
|
className="mt-2" rows={2} placeholder="Interne Notiz hinzufügen..."
|
|
value={noteDraft} onChange={(e) => setNoteDraft(e.target.value)}
|
|
/>
|
|
<Button size="sm" variant="tertiary" className="mt-2" isDisabled={!noteDraft.trim()} onPress={addAppNote}>
|
|
Notiz speichern
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)) : (
|
|
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm text-muted">
|
|
<ClipboardList size={24} />
|
|
Keine Anträge
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</SectionCard>
|
|
);
|
|
}
|