feat: add support tickets backend and dashboard
هذا الالتزام موجود في:
470
oudelaa_dashboard/app/(dashboard)/dashboard/support/page.tsx
Normal file
470
oudelaa_dashboard/app/(dashboard)/dashboard/support/page.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
"use client";
|
||||
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { ImageIcon, RefreshCcw, Send, UserRoundCheck } from "lucide-react";
|
||||
|
||||
import { NoPermissionState } from "@/components/auth/no-permission-state";
|
||||
import { useSuperAdminSession } from "@/components/auth/session-context";
|
||||
import { PageHeader } from "@/components/dashboard/page-header";
|
||||
import { PaginationControls } from "@/components/dashboard/pagination-controls";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { getItems, getPagination } from "@/lib/api/core";
|
||||
import {
|
||||
assignSupportTicket,
|
||||
getSupportTicket,
|
||||
listSupportTickets,
|
||||
replyToSupportTicket,
|
||||
updateSupportTicketStatus,
|
||||
} from "@/lib/api/support";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
|
||||
import type {
|
||||
ApiUser,
|
||||
SupportTicket,
|
||||
SupportTicketDetailResponse,
|
||||
SupportTicketStatus,
|
||||
SupportTicketsResponse,
|
||||
} from "@/types/api";
|
||||
|
||||
const statuses: SupportTicketStatus[] = ["open", "pending", "in_progress", "resolved", "closed"];
|
||||
const categories = ["account", "technical", "media", "payment", "collaboration", "other"];
|
||||
const priorities = ["low", "normal", "high", "urgent"];
|
||||
|
||||
function userLabel(user?: ApiUser | null) {
|
||||
if (!user) return "-";
|
||||
return user.stageName || user.name || user.username || user.email || user._id;
|
||||
}
|
||||
|
||||
function statusVariant(status: SupportTicketStatus): "success" | "muted" | "warning" | "danger" {
|
||||
if (status === "resolved" || status === "closed") return "success";
|
||||
if (status === "in_progress") return "warning";
|
||||
if (status === "pending") return "muted";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
export default function SupportPage() {
|
||||
const { permissions } = useSuperAdminSession();
|
||||
const canManageSupport = hasPermission(permissions, SUPERADMIN_PERMISSIONS.CASES_MANAGE);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState("open");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
const [priorityFilter, setPriorityFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [response, setResponse] = useState<SupportTicketsResponse | null>(null);
|
||||
const [selected, setSelected] = useState<SupportTicketDetailResponse | null>(null);
|
||||
const [reply, setReply] = useState("");
|
||||
const [replyImage, setReplyImage] = useState<File | null>(null);
|
||||
const [assignAdminId, setAssignAdminId] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const filtersRef = useRef({ statusFilter, categoryFilter, priorityFilter });
|
||||
|
||||
filtersRef.current = { statusFilter, categoryFilter, priorityFilter };
|
||||
|
||||
const loadTickets = useCallback(async () => {
|
||||
if (!canManageSupport) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const current = filtersRef.current;
|
||||
const tickets = await listSupportTickets({
|
||||
page,
|
||||
limit: 15,
|
||||
status: current.statusFilter === "all" ? undefined : current.statusFilter,
|
||||
category: current.categoryFilter === "all" ? undefined : current.categoryFilter,
|
||||
priority: current.priorityFilter === "all" ? undefined : current.priorityFilter,
|
||||
sortOrder: "desc",
|
||||
});
|
||||
setResponse(tickets);
|
||||
const items = getItems(tickets) as SupportTicket[];
|
||||
const currentSelectedId = selected?.ticket._id;
|
||||
const nextTicket = currentSelectedId
|
||||
? items.find((item) => item._id === currentSelectedId) ?? items[0]
|
||||
: items[0];
|
||||
if (nextTicket) {
|
||||
const detail = await getSupportTicket(nextTicket._id);
|
||||
setSelected(detail);
|
||||
setAssignAdminId(detail.ticket.assignedAdminId ?? "");
|
||||
} else {
|
||||
setSelected(null);
|
||||
setAssignAdminId("");
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: "Failed to load support tickets", description: String(error), variant: "danger" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canManageSupport, page, selected?.ticket._id, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTickets();
|
||||
}, [loadTickets]);
|
||||
|
||||
const tickets = getItems(response) as SupportTicket[];
|
||||
const summary = useMemo(
|
||||
() => ({
|
||||
open: tickets.filter((ticket) => ticket.status === "open").length,
|
||||
inProgress: tickets.filter((ticket) => ticket.status === "in_progress").length,
|
||||
resolved: tickets.filter((ticket) => ticket.status === "resolved").length,
|
||||
closed: tickets.filter((ticket) => ticket.status === "closed").length,
|
||||
}),
|
||||
[tickets],
|
||||
);
|
||||
|
||||
const selectTicket = async (ticketId: string) => {
|
||||
setUpdating(true);
|
||||
try {
|
||||
const detail = await getSupportTicket(ticketId);
|
||||
setSelected(detail);
|
||||
setAssignAdminId(detail.ticket.assignedAdminId ?? "");
|
||||
} catch (error) {
|
||||
toast({ title: "Failed to load ticket", description: String(error), variant: "danger" });
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
if (page === 1) {
|
||||
void loadTickets();
|
||||
return;
|
||||
}
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeStatus = async (status: SupportTicketStatus) => {
|
||||
if (!selected) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
await updateSupportTicketStatus(selected.ticket._id, status);
|
||||
await selectTicket(selected.ticket._id);
|
||||
await loadTickets();
|
||||
toast({ title: "Ticket status updated", description: status, variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: "Status update failed", description: String(error), variant: "danger" });
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const assignTicket = async () => {
|
||||
if (!selected) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
await assignSupportTicket(selected.ticket._id, assignAdminId.trim());
|
||||
await selectTicket(selected.ticket._id);
|
||||
toast({ title: "Assignment updated", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: "Assignment failed", description: String(error), variant: "danger" });
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendReply = async () => {
|
||||
if (!selected || (!reply.trim() && !replyImage)) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
await replyToSupportTicket(selected.ticket._id, reply.trim(), replyImage);
|
||||
setReply("");
|
||||
setReplyImage(null);
|
||||
await selectTicket(selected.ticket._id);
|
||||
await loadTickets();
|
||||
toast({ title: "Reply sent", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: "Reply failed", description: String(error), variant: "danger" });
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onImageChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setReplyImage(event.target.files?.[0] ?? null);
|
||||
};
|
||||
|
||||
if (!canManageSupport) {
|
||||
return (
|
||||
<div className="space-y-5 pb-8">
|
||||
<PageHeader title="Support" subtitle="Review user support tickets and reply from the dashboard." />
|
||||
<NoPermissionState description="This page needs the cases.manage permission." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5 pb-8">
|
||||
<PageHeader
|
||||
title="Support tickets"
|
||||
subtitle="Handle user support requests separately from reports and moderation cases."
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => void loadTickets()} disabled={loading}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Open</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold text-foreground">{summary.open}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>In progress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold text-foreground">{summary.inProgress}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resolved</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold text-foreground">{summary.resolved}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Closed</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold text-foreground">{summary.closed}</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-4">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{statuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All categories</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All priorities</SelectItem>
|
||||
{priorities.map((priority) => (
|
||||
<SelectItem key={priority} value={priority}>
|
||||
{priority}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" onClick={applyFilters} disabled={loading}>
|
||||
Apply
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-12">
|
||||
<Card className="xl:col-span-7">
|
||||
<CardHeader>
|
||||
<CardTitle>Ticket queue</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!tickets.length && !loading ? (
|
||||
<EmptyState title="No support tickets" description="No tickets match the selected filters." />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Subject</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last message</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tickets.map((ticket) => (
|
||||
<TableRow
|
||||
key={ticket._id}
|
||||
className={selected?.ticket._id === ticket._id ? "bg-primary/10" : undefined}
|
||||
onClick={() => void selectTicket(ticket._id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium text-foreground">{userLabel(ticket.user)}</div>
|
||||
<div className="text-xs text-muted-foreground">{ticket.user?.email ?? ticket.userId}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-[280px] truncate font-medium">{ticket.subject}</div>
|
||||
<div className="text-xs text-muted-foreground">{ticket.category}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ticket.priority === "urgent" || ticket.priority === "high" ? "danger" : "muted"}>
|
||||
{ticket.priority}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(ticket.status)}>{ticket.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDateTime(ticket.lastMessageAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<PaginationControls pagination={getPagination(response)} loading={loading} onPageChange={setPage} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="xl:col-span-5">
|
||||
<CardHeader>
|
||||
<CardTitle>Ticket details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!selected ? (
|
||||
<EmptyState title="Select a ticket" description="Choose a ticket to review user details and messages." />
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-border/70 bg-secondary/20 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{selected.ticket.user?.avatar ? (
|
||||
<Image
|
||||
src={selected.ticket.user.avatar}
|
||||
alt={userLabel(selected.ticket.user)}
|
||||
width={48}
|
||||
height={48}
|
||||
unoptimized
|
||||
className="h-12 w-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<UserRoundCheck className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-foreground">{userLabel(selected.ticket.user)}</div>
|
||||
<div className="text-sm text-muted-foreground">{selected.ticket.user?.username ?? "-"}</div>
|
||||
<div className="break-all text-xs text-muted-foreground">{selected.ticket.user?.email ?? "-"}</div>
|
||||
</div>
|
||||
<Badge variant={selected.ticket.user?.isDisabled ? "danger" : "success"}>
|
||||
{selected.ticket.user?.isDisabled ? "disabled" : "active"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4 space-y-1 text-sm">
|
||||
<div className="font-semibold text-foreground">{selected.ticket.subject}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{selected.ticket.category} / {selected.ticket.priority} / {formatDateTime(selected.ticket.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<Select value={selected.ticket.status} onValueChange={(value) => void changeStatus(value as SupportTicketStatus)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Admin id"
|
||||
value={assignAdminId}
|
||||
onChange={(event) => setAssignAdminId(event.target.value)}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => void assignTicket()} disabled={updating}>
|
||||
Assign
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[420px] space-y-3 overflow-y-auto rounded-xl border border-border/70 p-3">
|
||||
{selected.messages.map((message) => (
|
||||
<div
|
||||
key={message._id}
|
||||
className={`rounded-xl border border-border/70 p-3 ${
|
||||
message.senderRole === "user" ? "bg-secondary/20" : "bg-primary/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Badge variant={message.senderRole === "user" ? "muted" : "warning"}>{message.senderRole}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{formatDateTime(message.createdAt)}</span>
|
||||
</div>
|
||||
{message.message ? <p className="mt-2 whitespace-pre-wrap text-sm">{message.message}</p> : null}
|
||||
{message.attachmentUrl ? (
|
||||
<a href={message.attachmentUrl} target="_blank" rel="noreferrer" className="mt-3 block">
|
||||
<Image
|
||||
src={message.attachmentUrl}
|
||||
alt="Support attachment"
|
||||
width={640}
|
||||
height={260}
|
||||
unoptimized
|
||||
className="max-h-52 w-full rounded-lg object-cover"
|
||||
/>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-border/70 p-3">
|
||||
<Textarea
|
||||
placeholder="Write a reply"
|
||||
value={reply}
|
||||
onChange={(event) => setReply(event.target.value)}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-border/70 px-3 py-2 text-sm">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
{replyImage ? replyImage.name : "Attach image"}
|
||||
<input className="hidden" type="file" accept="image/*" onChange={onImageChange} />
|
||||
</label>
|
||||
<Button onClick={() => void sendReply()} disabled={updating || (!reply.trim() && !replyImage)}>
|
||||
<Send className="h-4 w-4" />
|
||||
Send reply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
المرجع في مشكلة جديدة
حظر مستخدم