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:
237
apps/webui/src/components/modules/open-tickets-manager.tsx
Normal file
237
apps/webui/src/components/modules/open-tickets-manager.tsx
Normal 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')}
|
||||
{' · '}
|
||||
<@{ticket.openerId}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user