Yimaru-Admin/src/pages/issues/IssuesPage.tsx
Yared Yemane 73f11ea1a0 Content admin: course hierarchy, sub-categories API, and stability fixes
- 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
2026-04-20 10:30:28 -07:00

1076 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}