Enhance ticket management with dashboard actions and priority settings

- Introduced a new `runTicketDashboardActionJob` to handle ticket actions (close, claim, set priority) with Discord side effects.
- Added a priority dropdown in the ticket control panel, allowing users to set ticket urgency levels.
- Updated WebUI to display open tickets with options to claim, close, or adjust priority, enhancing user interaction.
- Improved localization for new ticket management features in both English and German.
- Documented changes in phase tracking to reflect the new ticket dashboard functionalities.
This commit is contained in:
TheOnlyMace
2026-07-25 16:21:51 +02:00
parent ac5ecc9a1c
commit 5c11b68fc0
15 changed files with 710 additions and 14 deletions

View File

@@ -0,0 +1,237 @@
'use client';
import type { TicketDashboard, TicketPriority } from '@nexumi/shared';
import { ExternalLink, RefreshCw } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Badge, type BadgeProps } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
const STATUS_VARIANTS: Record<'OPEN' | 'CLAIMED', BadgeProps['variant']> = {
OPEN: 'secondary',
CLAIMED: 'success'
};
const PRIORITY_VARIANTS: Record<TicketPriority, BadgeProps['variant']> = {
LOW: 'outline',
NORMAL: 'secondary',
HIGH: 'default',
URGENT: 'destructive'
};
const PRIORITIES: TicketPriority[] = ['LOW', 'NORMAL', 'HIGH', 'URGENT'];
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
}
function discordChannelUrl(guildId: string, ticket: TicketDashboard): string | null {
const channelId = ticket.channelId ?? ticket.threadId;
if (!channelId) {
return null;
}
return `https://discord.com/channels/${guildId}/${channelId}`;
}
interface OpenTicketsManagerProps {
guildId: string;
initialTickets: TicketDashboard[];
}
export function OpenTicketsManager({ guildId, initialTickets }: OpenTicketsManagerProps) {
const t = useTranslations();
const [tickets, setTickets] = useState<TicketDashboard[]>(initialTickets);
const [pendingId, setPendingId] = useState<string | null>(null);
const [reasons, setReasons] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
async function reload() {
setLoading(true);
try {
const response = await fetch(`/api/guilds/${guildId}/tickets/open`);
const body = (await response.json().catch(() => null)) as { tickets?: TicketDashboard[] } | null;
if (response.ok && body?.tickets) {
setTickets(body.tickets);
} else {
toast.error(t('common.saveError'));
}
} catch {
toast.error(t('common.networkError'));
} finally {
setLoading(false);
}
}
async function runAction(
ticket: TicketDashboard,
payload:
| { action: 'close'; reason?: string }
| { action: 'claim' }
| { action: 'priority'; priority: TicketPriority }
) {
setPendingId(ticket.id);
try {
const response = await fetch(`/api/guilds/${guildId}/tickets/open/${ticket.id}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const body = (await response.json().catch(() => null)) as
| (TicketDashboard & { error?: string })
| null;
if (!response.ok || !body) {
toast.error(body?.error ?? t('common.saveError'));
return;
}
if (payload.action === 'close') {
setTickets((prev) => prev.filter((entry) => entry.id !== ticket.id));
toast.success(t('modulePages.tickets.openList.closed'));
} else {
setTickets((prev) => prev.map((entry) => (entry.id === ticket.id ? body : entry)));
toast.success(t('common.saveSuccess'));
}
} catch {
toast.error(t('common.networkError'));
} finally {
setPendingId(null);
}
}
return (
<FieldAnchor id="field-tickets-open">
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>{t('modulePages.tickets.openList.title')}</CardTitle>
<CardDescription>{t('modulePages.tickets.openList.description')}</CardDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={reload} disabled={loading}>
<RefreshCw className={`size-4 ${loading ? 'animate-spin' : ''}`} />
{t('modulePages.tickets.openList.refresh')}
</Button>
</CardHeader>
<CardContent className="space-y-3">
{!loading && tickets.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.tickets.openList.empty')}</p>
)}
{tickets.map((ticket) => {
const channelUrl = discordChannelUrl(guildId, ticket);
const busy = pendingId === ticket.id;
const statusKey = ticket.status === 'CLAIMED' ? 'CLAIMED' : 'OPEN';
return (
<div key={ticket.id} className="space-y-3 rounded-md border border-border p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<p className="font-medium">
{ticket.subject?.trim() || t('modulePages.tickets.openList.noSubject')}
</p>
<p className="text-xs text-muted-foreground">
{ticket.categoryName
? t('modulePages.tickets.openList.categoryLine', {
category: ticket.categoryName
})
: t('modulePages.tickets.openList.noCategory')}
{' · '}
&lt;@{ticket.openerId}&gt;
{ticket.claimedById ? (
<>
{' · '}
{t('modulePages.tickets.openList.claimedBy', {
user: `<@${ticket.claimedById}>`
})}
</>
) : null}
{' · '}
{formatDate(ticket.lastActivityAt)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={STATUS_VARIANTS[statusKey]}>
{t(`modulePages.tickets.openList.status.${statusKey}`)}
</Badge>
<Badge variant={PRIORITY_VARIANTS[ticket.priority]}>
{t(`modulePages.tickets.openList.priority.${ticket.priority}`)}
</Badge>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
className="sm:max-w-xs"
placeholder={t('modulePages.tickets.openList.closeReasonPlaceholder')}
value={reasons[ticket.id] ?? ''}
disabled={busy}
onChange={(event) =>
setReasons((prev) => ({ ...prev, [ticket.id]: event.target.value }))
}
/>
<div className="flex flex-wrap gap-2">
{channelUrl ? (
<Button type="button" variant="outline" size="sm" asChild>
<a href={channelUrl} target="_blank" rel="noreferrer">
<ExternalLink className="size-4" />
{t('modulePages.tickets.openList.openInDiscord')}
</a>
</Button>
) : null}
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy}
onClick={() => runAction(ticket, { action: 'claim' })}
>
{t('modulePages.tickets.openList.claim')}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={busy}
onClick={() =>
runAction(ticket, {
action: 'close',
reason: reasons[ticket.id]?.trim() || undefined
})
}
>
{t('modulePages.tickets.openList.close')}
</Button>
<Select
value={ticket.priority}
disabled={busy}
onValueChange={(priority) =>
runAction(ticket, {
action: 'priority',
priority: priority as TicketPriority
})
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIORITIES.map((priority) => (
<SelectItem key={priority} value={priority}>
{t(`modulePages.tickets.openList.priority.${priority}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
})}
</CardContent>
</Card>
</FieldAnchor>
);
}