Wrap next/link in a shared Link component that ships prefetch=false by default, so no route is ever prefetched (viewport or hover) anymore, and drop the DNS prefetch hint. Removes hidden background requests that were the source of intermittent issues.
450 lines
12 KiB
TypeScript
450 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ArrowLeft,
|
|
Calendar,
|
|
Globe,
|
|
Loader2,
|
|
Mail,
|
|
Send,
|
|
Shield,
|
|
User,
|
|
} from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
adminReplyTicket,
|
|
assignTicket,
|
|
updateTicketPriority,
|
|
updateTicketStatus,
|
|
} from "@/actions/tickets";
|
|
import Link from "@/components/link";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { useServerAction } from "@/hooks/use-server-action";
|
|
import { TICKET_PRIORITIES, TICKET_STATUSES } from "@/lib/validators/ticket";
|
|
|
|
interface TicketMessage {
|
|
id: number;
|
|
message: string;
|
|
isStaff: boolean;
|
|
createdAt: string;
|
|
user: { id: number; username: string; look: string; rank: number };
|
|
}
|
|
|
|
interface TicketInfo {
|
|
id: number;
|
|
subject: string;
|
|
category: string;
|
|
priority: string;
|
|
status: string;
|
|
createdAt: string;
|
|
closedAt: string | null;
|
|
creator: {
|
|
id: number;
|
|
username: string;
|
|
look: string;
|
|
rank: number;
|
|
mail: string;
|
|
ipRegister: string;
|
|
accountCreated: number;
|
|
};
|
|
assignee: { id: number; username: string } | null;
|
|
}
|
|
|
|
interface StaffMember {
|
|
id: number;
|
|
username: string;
|
|
}
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
open: "bg-[var(--admin-info-subtle)] text-[var(--admin-info)]",
|
|
in_progress: "bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]",
|
|
waiting: "bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]",
|
|
closed: "bg-muted text-muted-foreground",
|
|
};
|
|
|
|
const PRIORITY_COLORS: Record<string, string> = {
|
|
low: "text-muted-foreground",
|
|
normal: "text-[var(--admin-info)]",
|
|
high: "text-[var(--admin-warning)]",
|
|
urgent: "text-[var(--admin-error)]",
|
|
};
|
|
|
|
export function AdminTicketDetail({
|
|
ticket,
|
|
messages: initialMessages,
|
|
staffMembers,
|
|
currentUserId,
|
|
canEdit,
|
|
listHref = "/admin/tickets",
|
|
userLinkMode = "admin",
|
|
}: {
|
|
ticket: TicketInfo;
|
|
messages: TicketMessage[];
|
|
staffMembers: StaffMember[];
|
|
currentUserId: number;
|
|
canEdit: boolean;
|
|
listHref?: string;
|
|
userLinkMode?: "admin" | "public";
|
|
}) {
|
|
const [messages] = useState(initialMessages);
|
|
const [reply, setReply] = useState("");
|
|
const { run, isPending } = useServerAction();
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
function creatorHref() {
|
|
if (userLinkMode === "public") {
|
|
return `/u/${encodeURIComponent(ticket.creator.username)}`;
|
|
}
|
|
return `/admin/users/show/${ticket.creator.id}`;
|
|
}
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [messages]);
|
|
|
|
function handleReply() {
|
|
if (!reply.trim() || isPending) return;
|
|
run(
|
|
() => adminReplyTicket({ ticketId: ticket.id, message: reply.trim() }),
|
|
{
|
|
successMessage: "Reply sent!",
|
|
onSuccess: () => setReply(""),
|
|
},
|
|
);
|
|
}
|
|
|
|
function handleStatusChange(status: string) {
|
|
run(
|
|
() =>
|
|
updateTicketStatus({
|
|
ticketId: ticket.id,
|
|
status: status as (typeof TICKET_STATUSES)[number],
|
|
}),
|
|
{
|
|
successMessage: `Status changed to ${status}`,
|
|
},
|
|
);
|
|
}
|
|
|
|
function handleAssign(assigneeId: string) {
|
|
const id = assigneeId === "unassign" ? null : Number(assigneeId);
|
|
run(() => assignTicket({ ticketId: ticket.id, assigneeId: id }), {
|
|
successMessage: id ? "Ticket assigned" : "Ticket unassigned",
|
|
});
|
|
}
|
|
|
|
function handlePriorityChange(priority: string) {
|
|
run(
|
|
() =>
|
|
updateTicketPriority({
|
|
ticketId: ticket.id,
|
|
priority: priority as (typeof TICKET_PRIORITIES)[number],
|
|
}),
|
|
{
|
|
successMessage: `Priority changed to ${priority}`,
|
|
},
|
|
);
|
|
}
|
|
|
|
const isClosed = ticket.status === "closed";
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-start gap-4">
|
|
<Link href={listHref}>
|
|
<Button variant="ghost" size="icon">
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
</Link>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-sm text-muted-foreground font-mono">
|
|
#{ticket.id}
|
|
</span>
|
|
<Badge className={STATUS_COLORS[ticket.status]}>
|
|
{ticket.status.replace("_", " ")}
|
|
</Badge>
|
|
<Badge
|
|
variant="outline"
|
|
className={`capitalize ${PRIORITY_COLORS[ticket.priority]}`}
|
|
>
|
|
{ticket.priority}
|
|
</Badge>
|
|
<Badge variant="outline" className="capitalize">
|
|
{ticket.category}
|
|
</Badge>
|
|
</div>
|
|
<h1 className="text-2xl font-bold">{ticket.subject}</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-[1fr_300px]">
|
|
{/* Messages */}
|
|
<div className="space-y-4">
|
|
<Card className="overflow-hidden">
|
|
<div className="max-h-[600px] overflow-y-auto p-4 space-y-4">
|
|
{messages.map((msg) => (
|
|
<div key={msg.id} className="flex gap-3">
|
|
<div className="shrink-0">
|
|
<div className="w-9 h-9 rounded-full bg-accent flex items-center justify-center text-xs font-bold">
|
|
{msg.isStaff ? (
|
|
<Shield className="h-4 w-4 text-primary" />
|
|
) : (
|
|
<User className="h-4 w-4" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span
|
|
className={`text-xs font-semibold ${msg.isStaff ? "text-primary" : ""}`}
|
|
>
|
|
{msg.user.username}
|
|
{msg.isStaff && " (Staff)"}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{new Date(msg.createdAt).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className={`rounded-lg px-4 py-2.5 text-sm leading-relaxed ${
|
|
msg.isStaff
|
|
? "bg-primary/10 border border-primary/20"
|
|
: "bg-muted"
|
|
}`}
|
|
>
|
|
<p className="whitespace-pre-wrap break-words">
|
|
{msg.message}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Reply */}
|
|
{canEdit && !isClosed && (
|
|
<div className="border-t p-4">
|
|
<div className="flex gap-2">
|
|
<Textarea
|
|
value={reply}
|
|
onChange={(e) => setReply(e.target.value)}
|
|
placeholder="Reply as staff..."
|
|
rows={3}
|
|
maxLength={5000}
|
|
className="resize-none"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
handleReply();
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
onClick={handleReply}
|
|
disabled={isPending || !reply.trim()}
|
|
size="icon"
|
|
className="shrink-0 h-auto"
|
|
>
|
|
{isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Send className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground mt-1">
|
|
Ctrl+Enter to send. Your reply will be marked as staff.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{isClosed && (
|
|
<div className="border-t p-4 text-center text-sm text-muted-foreground">
|
|
Ticket closed{" "}
|
|
{ticket.closedAt
|
|
? `on ${new Date(ticket.closedAt).toLocaleDateString()}`
|
|
: ""}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div className="space-y-4">
|
|
{/* Actions */}
|
|
{canEdit && (
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm">Actions</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div>
|
|
<span className="text-xs text-muted-foreground mb-1 block">
|
|
Status
|
|
</span>
|
|
<Select
|
|
value={ticket.status}
|
|
onValueChange={handleStatusChange}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{TICKET_STATUSES.map((s) => (
|
|
<SelectItem
|
|
key={s}
|
|
value={s}
|
|
className="text-xs capitalize"
|
|
>
|
|
{s.replace("_", " ")}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<span className="text-xs text-muted-foreground mb-1 block">
|
|
Priority
|
|
</span>
|
|
<Select
|
|
value={ticket.priority}
|
|
onValueChange={handlePriorityChange}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{TICKET_PRIORITIES.map((p) => (
|
|
<SelectItem
|
|
key={p}
|
|
value={p}
|
|
className="text-xs capitalize"
|
|
>
|
|
{p}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<span className="text-xs text-muted-foreground mb-1 block">
|
|
Assign to
|
|
</span>
|
|
<Select
|
|
value={
|
|
ticket.assignee ? String(ticket.assignee.id) : "unassign"
|
|
}
|
|
onValueChange={handleAssign}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="Unassigned" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="unassign" className="text-xs">
|
|
Unassigned
|
|
</SelectItem>
|
|
{staffMembers.map((s) => (
|
|
<SelectItem
|
|
key={s.id}
|
|
value={String(s.id)}
|
|
className="text-xs"
|
|
>
|
|
{s.username}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={() => {
|
|
handleAssign(String(currentUserId));
|
|
}}
|
|
>
|
|
Assign to me
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* User info */}
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm">Requester</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
<Link
|
|
href={creatorHref()}
|
|
className="flex items-center gap-2 text-sm font-medium hover:text-primary"
|
|
>
|
|
<User className="h-3.5 w-3.5" />
|
|
{ticket.creator.username}
|
|
<Badge variant="outline" className="text-[10px]">
|
|
Rank {ticket.creator.rank}
|
|
</Badge>
|
|
</Link>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Mail className="h-3 w-3" />
|
|
{ticket.creator.mail}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Globe className="h-3 w-3" />
|
|
{ticket.creator.ipRegister}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Calendar className="h-3 w-3" />
|
|
Registered{" "}
|
|
{new Date(
|
|
ticket.creator.accountCreated * 1000,
|
|
).toLocaleDateString()}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Ticket info */}
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm">Details</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2 text-xs text-muted-foreground">
|
|
<div className="flex justify-between">
|
|
<span>Created</span>
|
|
<span>{new Date(ticket.createdAt).toLocaleString()}</span>
|
|
</div>
|
|
{ticket.closedAt && (
|
|
<div className="flex justify-between">
|
|
<span>Closed</span>
|
|
<span>{new Date(ticket.closedAt).toLocaleString()}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between">
|
|
<span>Messages</span>
|
|
<span>{messages.length}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|