- Sub-categories: load from GET categories/:id/sub-categories; SubCategoryCoursesPage - Course structure: levels/modules/sub-modules APIs; SubCoursesPage hierarchy browser - Sub-module detail: HumanLanguageSubModulePage for category routes; resolveSubModuleForCourse - Types and courses API: module sub-modules endpoint, hierarchy array guard - Misc: AppLayout/IssuesPage fixes, CoursesPage refactor, Human Language hierarchy page Made-with: Cursor
1076 lines
39 KiB
TypeScript
1076 lines
39 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
||
import {
|
||
Search,
|
||
ChevronDown,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
AlertCircle,
|
||
Eye,
|
||
RefreshCw,
|
||
Clock,
|
||
User,
|
||
Trash2,
|
||
X,
|
||
Info,
|
||
Bug,
|
||
Video,
|
||
BookOpen,
|
||
HelpCircle,
|
||
Loader2,
|
||
CheckCircle2,
|
||
XCircle,
|
||
ArrowUpCircle,
|
||
MessageCircle,
|
||
} from "lucide-react";
|
||
import { Button } from "../../components/ui/button";
|
||
import { Input } from "../../components/ui/input";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "../../components/ui/table";
|
||
import { Badge } from "../../components/ui/badge";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogDescription,
|
||
} from "../../components/ui/dialog";
|
||
import { cn } from "../../lib/utils";
|
||
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
||
import {
|
||
getIssues,
|
||
getIssueById,
|
||
updateIssueStatus,
|
||
deleteIssue,
|
||
createIssue,
|
||
} from "../../api/issues.api";
|
||
import type { Issue, IssueFilters } from "../../types/issue.types";
|
||
|
||
// ── Status configuration ───────────────────────────────────────────
|
||
const STATUSES = ["pending", "in_progress", "resolved", "closed"] as const;
|
||
|
||
const ISSUE_TYPES = ["bug", "video", "course", "account", "payment", "other"] as const;
|
||
|
||
function getStatusConfig(status: string): {
|
||
label: string;
|
||
classes: string;
|
||
icon: typeof CheckCircle2;
|
||
} {
|
||
switch (status) {
|
||
case "pending":
|
||
return {
|
||
label: "Pending",
|
||
classes: "bg-amber-50 text-amber-700 border-amber-200",
|
||
icon: Clock,
|
||
};
|
||
case "in_progress":
|
||
return {
|
||
label: "In Progress",
|
||
classes: "bg-blue-50 text-blue-700 border-blue-200",
|
||
icon: Loader2,
|
||
};
|
||
case "resolved":
|
||
return {
|
||
label: "Resolved",
|
||
classes: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||
icon: CheckCircle2,
|
||
};
|
||
case "closed":
|
||
return {
|
||
label: "Closed",
|
||
classes: "bg-grayScale-100 text-grayScale-500 border-grayScale-200",
|
||
icon: XCircle,
|
||
};
|
||
default:
|
||
return {
|
||
label: status,
|
||
classes: "bg-grayScale-100 text-grayScale-600 border-grayScale-200",
|
||
icon: HelpCircle,
|
||
};
|
||
}
|
||
}
|
||
|
||
function getIssueTypeConfig(type: string | null | undefined): {
|
||
label: string;
|
||
classes: string;
|
||
icon: typeof Bug;
|
||
} {
|
||
const t = String(type ?? "").trim();
|
||
switch (t) {
|
||
case "bug":
|
||
return {
|
||
label: "Bug",
|
||
classes: "bg-red-50 text-red-700 border-red-200",
|
||
icon: Bug,
|
||
};
|
||
case "video":
|
||
return {
|
||
label: "Video",
|
||
classes: "bg-violet-50 text-violet-700 border-violet-200",
|
||
icon: Video,
|
||
};
|
||
case "course":
|
||
return {
|
||
label: "Course",
|
||
classes: "bg-brand-500 text-white border-brand-500",
|
||
icon: BookOpen,
|
||
};
|
||
case "account":
|
||
return {
|
||
label: "Account",
|
||
classes: "bg-sky-50 text-sky-700 border-sky-200",
|
||
icon: User,
|
||
};
|
||
case "payment":
|
||
return {
|
||
label: "Payment",
|
||
classes: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||
icon: ArrowUpCircle,
|
||
};
|
||
default:
|
||
return {
|
||
label: t ? t.charAt(0).toUpperCase() + t.slice(1) : "Other",
|
||
classes: "bg-grayScale-100 text-grayScale-600 border-grayScale-200",
|
||
icon: HelpCircle,
|
||
};
|
||
}
|
||
}
|
||
|
||
function formatDate(dateStr: string): string {
|
||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||
year: "numeric",
|
||
month: "short",
|
||
day: "numeric",
|
||
});
|
||
}
|
||
|
||
function formatDateTime(dateStr: string): string {
|
||
return new Date(dateStr).toLocaleString("en-US", {
|
||
year: "numeric",
|
||
month: "short",
|
||
day: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
function getRelativeTime(dateStr: string): string {
|
||
const now = new Date();
|
||
const date = new Date(dateStr);
|
||
const diffMs = now.getTime() - date.getTime();
|
||
const diffMins = Math.floor(diffMs / 60000);
|
||
const diffHours = Math.floor(diffMs / 3600000);
|
||
const diffDays = Math.floor(diffMs / 86400000);
|
||
|
||
if (diffMins < 1) return "Just now";
|
||
if (diffMins < 60) return `${diffMins}m ago`;
|
||
if (diffHours < 24) return `${diffHours}h ago`;
|
||
if (diffDays < 7) return `${diffDays}d ago`;
|
||
return formatDate(dateStr);
|
||
}
|
||
|
||
function formatRoleLabel(role: string | null | undefined): string {
|
||
const r = String(role ?? "").trim();
|
||
if (!r) return "—";
|
||
return r
|
||
.split("_")
|
||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||
.join(" ");
|
||
}
|
||
|
||
// ── Main Component ─────────────────────────────────────────────────
|
||
export function IssuesPage() {
|
||
const [issues, setIssues] = useState<Issue[]>([]);
|
||
const [totalCount, setTotalCount] = useState(0);
|
||
const [loading, setLoading] = useState(false);
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(10);
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [statusFilter, setStatusFilter] = useState("");
|
||
const [typeFilter, setTypeFilter] = useState("");
|
||
|
||
// Detail dialog
|
||
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
|
||
// Delete confirmation
|
||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||
const [issueToDelete, setIssueToDelete] = useState<Issue | null>(null);
|
||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||
|
||
// Status update
|
||
const [statusUpdating, setStatusUpdating] = useState<number | null>(null);
|
||
|
||
// Create issue dialog (admin-created)
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [createSubject, setCreateSubject] = useState("");
|
||
const [createType, setCreateType] = useState<string>("bug");
|
||
const [createDescription, setCreateDescription] = useState("");
|
||
const [createDevice, setCreateDevice] = useState("");
|
||
const [createBrowser, setCreateBrowser] = useState("");
|
||
const [createSubmitting, setCreateSubmitting] = useState(false);
|
||
|
||
const fetchIssues = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const filters: IssueFilters = {
|
||
limit: pageSize,
|
||
offset: (page - 1) * pageSize,
|
||
};
|
||
const res = await getIssues(filters);
|
||
const payload = res.data?.data;
|
||
setIssues(Array.isArray(payload?.issues) ? payload.issues : []);
|
||
setTotalCount(typeof payload?.total_count === "number" ? payload.total_count : 0);
|
||
} catch (error) {
|
||
console.error("Failed to fetch issues:", error);
|
||
setIssues([]);
|
||
setTotalCount(0);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [page, pageSize]);
|
||
|
||
useEffect(() => {
|
||
fetchIssues();
|
||
}, [fetchIssues]);
|
||
|
||
const handleViewDetail = async (issueId: number) => {
|
||
setDialogOpen(true);
|
||
setDetailLoading(true);
|
||
try {
|
||
const res = await getIssueById(issueId);
|
||
setSelectedIssue(res.data?.data ?? null);
|
||
} catch (error) {
|
||
console.error("Failed to fetch issue detail:", error);
|
||
} finally {
|
||
setDetailLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleStatusChange = async (issueId: number, newStatus: string) => {
|
||
setStatusUpdating(issueId);
|
||
try {
|
||
await updateIssueStatus(issueId, newStatus);
|
||
// Update the issue in the list
|
||
setIssues((prev) =>
|
||
prev.map((issue) =>
|
||
issue.id === issueId ? { ...issue, status: newStatus } : issue
|
||
)
|
||
);
|
||
// Also update the detail dialog if it's showing this issue
|
||
if (selectedIssue?.id === issueId) {
|
||
setSelectedIssue((prev) => (prev ? { ...prev, status: newStatus } : prev));
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to update issue status:", error);
|
||
} finally {
|
||
setStatusUpdating(null);
|
||
}
|
||
};
|
||
|
||
const handleDeleteClick = (issue: Issue) => {
|
||
setIssueToDelete(issue);
|
||
setDeleteDialogOpen(true);
|
||
};
|
||
|
||
const handleDeleteConfirm = async () => {
|
||
if (!issueToDelete) return;
|
||
setDeleteLoading(true);
|
||
try {
|
||
await deleteIssue(issueToDelete.id);
|
||
setDeleteDialogOpen(false);
|
||
setIssueToDelete(null);
|
||
// Close detail dialog if the deleted issue was being viewed
|
||
if (selectedIssue?.id === issueToDelete.id) {
|
||
setDialogOpen(false);
|
||
setSelectedIssue(null);
|
||
}
|
||
fetchIssues();
|
||
} catch (error) {
|
||
console.error("Failed to delete issue:", error);
|
||
} finally {
|
||
setDeleteLoading(false);
|
||
}
|
||
};
|
||
|
||
const hasActiveFilters = statusFilter || typeFilter;
|
||
|
||
const clearFilters = () => {
|
||
setSearchQuery("");
|
||
setStatusFilter("");
|
||
setTypeFilter("");
|
||
setPage(1);
|
||
};
|
||
|
||
// Client-side filtering (status, type, search)
|
||
const filteredIssues = (Array.isArray(issues) ? issues : []).filter((issue) => {
|
||
if (statusFilter && issue.status !== statusFilter) return false;
|
||
if (typeFilter && issue.issue_type !== typeFilter) return false;
|
||
if (searchQuery) {
|
||
const q = searchQuery.toLowerCase();
|
||
const subject = String(issue.subject ?? "").toLowerCase();
|
||
const description = String(issue.description ?? "").toLowerCase();
|
||
const issueType = String(issue.issue_type ?? "").toLowerCase();
|
||
return subject.includes(q) || description.includes(q) || issueType.includes(q);
|
||
}
|
||
return true;
|
||
});
|
||
|
||
// Pagination
|
||
const pageCount = Math.max(1, Math.ceil(totalCount / pageSize));
|
||
const safePage = Math.min(page, pageCount);
|
||
const handlePrev = () => safePage > 1 && setPage(safePage - 1);
|
||
const handleNext = () => safePage < pageCount && setPage(safePage + 1);
|
||
|
||
const getPageNumbers = () => {
|
||
const pages: (number | string)[] = [];
|
||
if (pageCount <= 7) {
|
||
for (let i = 1; i <= pageCount; i++) pages.push(i);
|
||
} else {
|
||
pages.push(1, 2, 3);
|
||
if (safePage > 4) pages.push("...");
|
||
if (safePage > 3 && safePage < pageCount - 2) pages.push(safePage);
|
||
if (safePage < pageCount - 3) pages.push("...");
|
||
pages.push(pageCount);
|
||
}
|
||
return pages;
|
||
};
|
||
|
||
const startEntry = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1;
|
||
const endEntry = Math.min(safePage * pageSize, totalCount);
|
||
|
||
// Stats
|
||
const pendingCount = issues.filter((i) => i.status === "pending").length;
|
||
const inProgressCount = issues.filter((i) => i.status === "in_progress").length;
|
||
const resolvedCount = issues.filter((i) => i.status === "resolved").length;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-grayScale-600">Issue Reports</h1>
|
||
<p className="text-sm text-grayScale-400">
|
||
Review and manage user-reported issues across the platform.
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
className="gap-2"
|
||
onClick={() => {
|
||
setPage(1);
|
||
fetchIssues();
|
||
}}
|
||
>
|
||
{loading ? <SpinnerIcon className="h-4 w-4" /> : <RefreshCw className="h-4 w-4" />}
|
||
Refresh
|
||
</Button>
|
||
<Button
|
||
className="gap-2 bg-brand-500 text-white hover:bg-brand-600"
|
||
onClick={() => setCreateOpen(true)}
|
||
>
|
||
<MessageCircle className="h-4 w-4" />
|
||
New Issue
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stats cards */}
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
|
||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-brand-100 text-brand-600">
|
||
<AlertCircle className="h-5 w-5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold text-grayScale-600">{totalCount}</p>
|
||
<p className="text-xs text-grayScale-400">Total Issues</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
|
||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-amber-100 text-amber-600">
|
||
<Clock className="h-5 w-5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold text-grayScale-600">{pendingCount}</p>
|
||
<p className="text-xs text-grayScale-400">Pending</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
|
||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-blue-100 text-blue-600">
|
||
<Loader2 className="h-5 w-5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold text-grayScale-600">{inProgressCount}</p>
|
||
<p className="text-xs text-grayScale-400">In Progress</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4 rounded-xl border bg-white p-4">
|
||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-emerald-100 text-emerald-600">
|
||
<CheckCircle2 className="h-5 w-5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold text-grayScale-600">{resolvedCount}</p>
|
||
<p className="text-xs text-grayScale-400">Resolved</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filters */}
|
||
<div className="flex flex-wrap items-center gap-3 rounded-xl border bg-white p-4">
|
||
<div className="relative flex-1 min-w-[200px]">
|
||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||
<Input
|
||
placeholder="Search by subject, description, or type..."
|
||
className="pl-9"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="relative">
|
||
<select
|
||
value={statusFilter}
|
||
onChange={(e) => {
|
||
setStatusFilter(e.target.value);
|
||
setPage(1);
|
||
}}
|
||
className="h-10 appearance-none rounded-lg border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
|
||
>
|
||
<option value="">Status: All</option>
|
||
{STATUSES.map((s) => (
|
||
<option key={s} value={s}>
|
||
{getStatusConfig(s).label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||
</div>
|
||
|
||
<div className="relative">
|
||
<select
|
||
value={typeFilter}
|
||
onChange={(e) => {
|
||
setTypeFilter(e.target.value);
|
||
setPage(1);
|
||
}}
|
||
className="h-10 appearance-none rounded-lg border bg-white pl-3 pr-8 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
|
||
>
|
||
<option value="">Type: All</option>
|
||
{ISSUE_TYPES.map((t) => (
|
||
<option key={t} value={t}>
|
||
{getIssueTypeConfig(t).label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<ChevronDown className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||
</div>
|
||
|
||
{hasActiveFilters && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={clearFilters}
|
||
className="gap-1 text-grayScale-400 hover:text-grayScale-600"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
Clear
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Table */}
|
||
<div className="rounded-xl border bg-white">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>SUBJECT</TableHead>
|
||
<TableHead>TYPE</TableHead>
|
||
<TableHead>STATUS</TableHead>
|
||
<TableHead>REPORTER</TableHead>
|
||
<TableHead>CREATED</TableHead>
|
||
<TableHead className="text-right">ACTIONS</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
|
||
<TableBody>
|
||
{loading ? (
|
||
<TableRow>
|
||
<TableCell colSpan={6} className="text-center py-12">
|
||
<div className="flex flex-col items-center gap-3">
|
||
<SpinnerIcon className="h-6 w-6" />
|
||
<span className="text-sm text-grayScale-400">Loading issues...</span>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
) : filteredIssues.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={6} className="text-center py-12">
|
||
<div className="flex flex-col items-center gap-3">
|
||
<AlertCircle className="h-8 w-8 text-grayScale-200" />
|
||
<div>
|
||
<p className="text-sm font-medium text-grayScale-500">No issues found</p>
|
||
<p className="text-xs text-grayScale-400 mt-1">
|
||
{hasActiveFilters || searchQuery
|
||
? "Try adjusting your filters or search query"
|
||
: "Reported issues will appear here"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
filteredIssues.map((issue) => {
|
||
const typeConfig = getIssueTypeConfig(issue.issue_type);
|
||
const statusConfig = getStatusConfig(issue.status);
|
||
const TypeIcon = typeConfig.icon;
|
||
|
||
return (
|
||
<TableRow key={issue.id} className="group">
|
||
<TableCell>
|
||
<div className="flex items-start gap-3 max-w-[300px]">
|
||
<div className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-50 text-grayScale-400 group-hover:bg-brand-500 group-hover:text-white transition-colors">
|
||
<TypeIcon className="h-4 w-4" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-medium text-grayScale-600 truncate">
|
||
{issue.subject?.trim() ? issue.subject : "—"}
|
||
</p>
|
||
<p className="text-xs text-grayScale-400 truncate mt-0.5">
|
||
{issue.description?.trim() ? issue.description : "No description"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium",
|
||
typeConfig.classes
|
||
)}
|
||
>
|
||
{typeConfig.label}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="relative" onClick={(e) => e.stopPropagation()}>
|
||
<select
|
||
value={issue.status}
|
||
disabled={statusUpdating === issue.id}
|
||
onChange={(e) => handleStatusChange(issue.id, e.target.value)}
|
||
className={cn(
|
||
"h-8 appearance-none rounded-full border pl-3 pr-7 text-xs font-medium cursor-pointer focus:outline-none focus:ring-2 focus:ring-ring transition-colors",
|
||
statusConfig.classes,
|
||
statusUpdating === issue.id && "opacity-50 cursor-wait"
|
||
)}
|
||
>
|
||
{STATUSES.map((s) => (
|
||
<option key={s} value={s}>
|
||
{getStatusConfig(s).label}
|
||
</option>
|
||
))}
|
||
{!STATUSES.includes(issue.status as (typeof STATUSES)[number]) && issue.status ? (
|
||
<option value={issue.status}>{getStatusConfig(issue.status).label}</option>
|
||
) : null}
|
||
</select>
|
||
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 pointer-events-none opacity-50" />
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex items-center gap-2">
|
||
<div className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-grayScale-100 text-grayScale-500">
|
||
<User className="h-3.5 w-3.5" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm font-medium text-grayScale-600">
|
||
User #{issue.user_id}
|
||
</p>
|
||
<p className="text-xs text-grayScale-400">
|
||
{formatRoleLabel(issue.user_role)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div>
|
||
<p className="text-sm text-grayScale-600">
|
||
{formatDate(issue.created_at)}
|
||
</p>
|
||
<p className="text-xs text-grayScale-400">
|
||
{getRelativeTime(issue.created_at)}
|
||
</p>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-8 w-8 p-0"
|
||
onClick={() => handleViewDetail(issue.id)}
|
||
>
|
||
<Eye className="h-4 w-4 text-grayScale-400" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-8 w-8 p-0 hover:bg-red-50 hover:text-red-600"
|
||
onClick={() => handleDeleteClick(issue)}
|
||
>
|
||
<Trash2 className="h-4 w-4 text-grayScale-400" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
|
||
{/* Pagination */}
|
||
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
|
||
<div className="flex items-center gap-2">
|
||
<span>Showing</span>
|
||
<span className="font-medium text-grayScale-600">
|
||
{startEntry}–{endEntry}
|
||
</span>
|
||
<span>of</span>
|
||
<span className="font-medium text-grayScale-600">{totalCount}</span>
|
||
<span className="mr-4">entries</span>
|
||
<span className="border-l pl-4">Rows per page</span>
|
||
<div className="relative">
|
||
<select
|
||
value={pageSize}
|
||
onChange={(e) => {
|
||
setPageSize(Number(e.target.value));
|
||
setPage(1);
|
||
}}
|
||
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
||
>
|
||
{[5, 10, 20, 30, 50].map((size) => (
|
||
<option key={size} value={size}>
|
||
{size}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<ChevronDown className="absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400 pointer-events-none" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={handlePrev}
|
||
disabled={safePage === 1}
|
||
className={cn(
|
||
"h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
|
||
safePage === 1 && "opacity-50 cursor-not-allowed"
|
||
)}
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</button>
|
||
|
||
{getPageNumbers().map((n, idx) =>
|
||
typeof n === "string" ? (
|
||
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
|
||
...
|
||
</span>
|
||
) : (
|
||
<button
|
||
key={n}
|
||
type="button"
|
||
onClick={() => setPage(n)}
|
||
className={cn(
|
||
"h-8 w-8 rounded-md border text-sm font-medium",
|
||
n === safePage
|
||
? "border-brand-500 bg-brand-500 text-white"
|
||
: "bg-white text-grayScale-600 hover:bg-grayScale-50"
|
||
)}
|
||
>
|
||
{n}
|
||
</button>
|
||
)
|
||
)}
|
||
|
||
<button
|
||
onClick={handleNext}
|
||
disabled={safePage === pageCount}
|
||
className={cn(
|
||
"h-8 w-8 flex items-center justify-center rounded-md border bg-white text-grayScale-500",
|
||
safePage === pageCount && "opacity-50 cursor-not-allowed"
|
||
)}
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Detail Dialog */}
|
||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
<DialogContent className="max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<Info className="h-5 w-5 text-brand-500" />
|
||
Issue Detail
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
Full details for this reported issue.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{detailLoading ? (
|
||
<div className="flex items-center justify-center py-8">
|
||
<SpinnerIcon className="h-6 w-6" />
|
||
</div>
|
||
) : selectedIssue ? (
|
||
<div className="space-y-4">
|
||
{/* Subject & badges */}
|
||
<div>
|
||
<h3 className="text-base font-semibold text-grayScale-600 mb-2">
|
||
{selectedIssue.subject}
|
||
</h3>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium",
|
||
getIssueTypeConfig(selectedIssue.issue_type).classes
|
||
)}
|
||
>
|
||
{getIssueTypeConfig(selectedIssue.issue_type).label}
|
||
</span>
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium",
|
||
getStatusConfig(selectedIssue.status).classes
|
||
)}
|
||
>
|
||
{getStatusConfig(selectedIssue.status).label}
|
||
</span>
|
||
<Badge variant="secondary" className="text-xs">
|
||
ID #{selectedIssue.id}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Description */}
|
||
<div className="rounded-lg bg-grayScale-50 p-3">
|
||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-400 mb-1.5">
|
||
Description
|
||
</p>
|
||
<p className="text-sm text-grayScale-600 leading-relaxed">
|
||
{selectedIssue.description}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Detail grid */}
|
||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||
<DetailItem
|
||
icon={<User className="h-4 w-4" />}
|
||
label="Reporter"
|
||
value={`User #${selectedIssue.user_id}`}
|
||
/>
|
||
<DetailItem
|
||
icon={<Info className="h-4 w-4" />}
|
||
label="Role"
|
||
value={formatRoleLabel(selectedIssue.user_role)}
|
||
/>
|
||
<DetailItem
|
||
icon={<Clock className="h-4 w-4" />}
|
||
label="Created"
|
||
value={formatDateTime(selectedIssue.created_at)}
|
||
/>
|
||
<DetailItem
|
||
icon={<RefreshCw className="h-4 w-4" />}
|
||
label="Updated"
|
||
value={formatDateTime(selectedIssue.updated_at)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Status changer */}
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-400 mb-2">
|
||
Update Status
|
||
</p>
|
||
<div className="flex gap-2 flex-wrap">
|
||
{STATUSES.map((s) => {
|
||
const config = getStatusConfig(s);
|
||
const isActive = selectedIssue.status === s;
|
||
return (
|
||
<button
|
||
key={s}
|
||
disabled={statusUpdating === selectedIssue.id}
|
||
onClick={() => handleStatusChange(selectedIssue.id, s)}
|
||
className={cn(
|
||
"inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
|
||
isActive
|
||
? cn(config.classes, "ring-2 ring-offset-1 ring-current")
|
||
: "bg-white text-grayScale-500 border-grayScale-200 hover:bg-grayScale-50",
|
||
statusUpdating === selectedIssue.id && "opacity-50 cursor-wait"
|
||
)}
|
||
>
|
||
{config.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Metadata */}
|
||
{selectedIssue.metadata &&
|
||
Object.keys(selectedIssue.metadata).length > 0 && (
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wider text-grayScale-400 mb-2">
|
||
Metadata
|
||
</p>
|
||
<div className="rounded-lg border bg-grayScale-50 p-3">
|
||
<div className="space-y-1.5">
|
||
{Object.entries(selectedIssue.metadata).map(([key, value]) => (
|
||
<div key={key} className="flex items-baseline justify-between gap-4">
|
||
<span className="text-xs font-medium text-grayScale-400 capitalize">
|
||
{key.replace(/_/g, " ")}
|
||
</span>
|
||
<span className="text-xs text-grayScale-600 font-mono text-right">
|
||
{String(value)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Actions */}
|
||
<div className="flex justify-end gap-2 pt-2 border-t">
|
||
<Button
|
||
variant="destructive"
|
||
size="sm"
|
||
className="gap-1.5"
|
||
onClick={() => {
|
||
setDialogOpen(false);
|
||
handleDeleteClick(selectedIssue);
|
||
}}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
Delete Issue
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Create Issue Dialog */}
|
||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||
<DialogContent className="max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<MessageCircle className="h-5 w-5 text-brand-500" />
|
||
<span>Create admin issue</span>
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
Log an issue directly from the admin panel so it can be tracked and resolved.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||
Subject
|
||
</label>
|
||
<Input
|
||
placeholder="Short summary of the issue"
|
||
value={createSubject}
|
||
onChange={(e) => setCreateSubject(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||
Type
|
||
</label>
|
||
<select
|
||
value={createType}
|
||
onChange={(e) => setCreateType(e.target.value)}
|
||
className="h-10 w-full rounded-lg border bg-white px-3 text-sm text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-ring"
|
||
>
|
||
{ISSUE_TYPES.map((t) => (
|
||
<option key={t} value={t}>
|
||
{getIssueTypeConfig(t).label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||
Description
|
||
</label>
|
||
<textarea
|
||
className="min-h-[100px] w-full rounded-lg border bg-white px-3 py-2 text-sm text-grayScale-700 placeholder:text-grayScale-400 focus:outline-none focus:ring-2 focus:ring-ring"
|
||
placeholder="Describe what happened, steps to reproduce, and any context that might help."
|
||
value={createDescription}
|
||
onChange={(e) => setCreateDescription(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div>
|
||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||
Device (optional)
|
||
</label>
|
||
<Input
|
||
placeholder="e.g. iPhone 14"
|
||
value={createDevice}
|
||
onChange={(e) => setCreateDevice(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">
|
||
Browser (optional)
|
||
</label>
|
||
<Input
|
||
placeholder="e.g. Safari 17"
|
||
value={createBrowser}
|
||
onChange={(e) => setCreateBrowser(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-5 flex items-center justify-end gap-2">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
setCreateOpen(false);
|
||
setCreateSubject("");
|
||
setCreateDescription("");
|
||
setCreateType("bug");
|
||
setCreateDevice("");
|
||
setCreateBrowser("");
|
||
}}
|
||
disabled={createSubmitting}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
className="bg-brand-500 text-white hover:bg-brand-600"
|
||
disabled={createSubmitting || !createSubject.trim() || !createDescription.trim()}
|
||
onClick={async () => {
|
||
if (!createSubject.trim() || !createDescription.trim()) return;
|
||
setCreateSubmitting(true);
|
||
try {
|
||
const payload: any = {
|
||
subject: createSubject.trim(),
|
||
description: createDescription.trim(),
|
||
issue_type: createType,
|
||
};
|
||
const metadata: Record<string, string> = {};
|
||
if (createDevice.trim()) metadata.device = createDevice.trim();
|
||
if (createBrowser.trim()) metadata.browser = createBrowser.trim();
|
||
if (Object.keys(metadata).length > 0) {
|
||
payload.metadata = metadata;
|
||
}
|
||
|
||
await createIssue(payload);
|
||
|
||
setCreateOpen(false);
|
||
setCreateSubject("");
|
||
setCreateDescription("");
|
||
setCreateType("bug");
|
||
setCreateDevice("");
|
||
setCreateBrowser("");
|
||
fetchIssues();
|
||
} catch (error) {
|
||
console.error("Failed to create issue:", error);
|
||
} finally {
|
||
setCreateSubmitting(false);
|
||
}
|
||
}}
|
||
>
|
||
{createSubmitting ? "Creating..." : "Create Issue"}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Delete Confirmation Dialog */}
|
||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||
<DialogContent className="max-w-sm">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||
<Trash2 className="h-5 w-5" />
|
||
Delete Issue
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
Are you sure you want to delete this issue? This action cannot be undone.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{issueToDelete && (
|
||
<div className="rounded-lg bg-red-50 border border-red-100 p-3">
|
||
<p className="text-sm font-medium text-red-700">{issueToDelete.subject}</p>
|
||
<p className="text-xs text-red-500 mt-0.5">Issue #{issueToDelete.id}</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
setDeleteDialogOpen(false);
|
||
setIssueToDelete(null);
|
||
}}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
size="sm"
|
||
className="gap-1.5"
|
||
disabled={deleteLoading}
|
||
onClick={handleDeleteConfirm}
|
||
>
|
||
{deleteLoading ? (
|
||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||
) : (
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
)}
|
||
{deleteLoading ? "Deleting..." : "Delete"}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Sub-components ─────────────────────────────────────────────────
|
||
|
||
function DetailItem({
|
||
icon,
|
||
label,
|
||
value,
|
||
}: {
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
value: string;
|
||
}) {
|
||
return (
|
||
<div className="flex items-start gap-2.5 rounded-lg border bg-white p-2.5">
|
||
<div className="mt-0.5 text-grayScale-400">{icon}</div>
|
||
<div className="min-w-0">
|
||
<p className="text-xs text-grayScale-400">{label}</p>
|
||
<p className="text-sm font-medium text-grayScale-600 truncate" title={value}>
|
||
{value}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|