Some checks failed
build-and-release / build (push) Failing after 1m16s
- verification/[id].tsx: "Add Note" in the Reviewer Notes panel had no
onClick — wired to the existing POST /api/admin/verifications/:id/notes
endpoint.
- approval/index.tsx: Decision Notes textarea was uncontrolled and its
"Add Note" button was a no-op; there's no standalone notes endpoint
for approval_requests, so the textarea is now bound to state and its
value is sent as the rejection reason when the reviewer rejects
(the only channel the backend currently supports).
- support.tsx: removed a no-op onClick={() => {}} on the case row —
the row isn't actually clickable, only the adjacent "View" link is
(though that link's target route doesn't exist yet — flagged
separately, out of scope here).
745 lines
31 KiB
TypeScript
745 lines
31 KiB
TypeScript
import { createResource, createSignal, createMemo, Show, For } from "solid-js";
|
||
import { A } from "@solidjs/router";
|
||
|
||
const API = "";
|
||
|
||
function getToken(): string {
|
||
return typeof sessionStorage !== "undefined"
|
||
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
|
||
: "";
|
||
}
|
||
|
||
function authHeaders(contentType = false): Record<string, string> {
|
||
const token = getToken();
|
||
return {
|
||
Accept: "application/json",
|
||
...(contentType ? { "Content-Type": "application/json" } : {}),
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
};
|
||
}
|
||
|
||
type SupportCase = {
|
||
id: string;
|
||
title: string;
|
||
description: string;
|
||
type:
|
||
| "platform_issue"
|
||
| "customer_query"
|
||
| "professional_query"
|
||
| "billing_issue"
|
||
| "lead_dispute";
|
||
priority: "low" | "medium" | "high" | "critical";
|
||
status: "new" | "in_progress" | "waiting_for_user" | "resolved" | "closed";
|
||
requesterName?: string;
|
||
requesterEmail?: string;
|
||
updatedAt: string;
|
||
createdAt: string;
|
||
};
|
||
|
||
type AssigneeOption = {
|
||
id: string;
|
||
name: string;
|
||
email?: string;
|
||
};
|
||
|
||
const STATUS_OPTIONS: SupportCase["status"][] = [
|
||
"new",
|
||
"in_progress",
|
||
"waiting_for_user",
|
||
"resolved",
|
||
"closed",
|
||
];
|
||
const TYPE_OPTIONS: SupportCase["type"][] = [
|
||
"platform_issue",
|
||
"customer_query",
|
||
"professional_query",
|
||
"billing_issue",
|
||
"lead_dispute",
|
||
];
|
||
const PRIORITY_OPTIONS: SupportCase["priority"][] = ["low", "medium", "high", "critical"];
|
||
|
||
function formatValue(input: string): string {
|
||
return input.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||
}
|
||
|
||
function typeBadgeStyle(type: string): string {
|
||
const map: Record<string, string> = {
|
||
platform_issue: "background:#dbeafe;color:#1d4ed8",
|
||
customer_query: "background:#dcfce7;color:#15803d",
|
||
billing_issue: "background:#ffedd5;color:#c2410c",
|
||
lead_dispute: "background:#fee2e2;color:#b91c1c",
|
||
professional_query: "background:#f3e8ff;color:#7e22ce",
|
||
};
|
||
return map[type] || "background:#f1f5f9;color:#475569";
|
||
}
|
||
|
||
function priorityBadgeStyle(priority: string): string {
|
||
const map: Record<string, string> = {
|
||
low: "background:#f1f5f9;color:#475569",
|
||
medium: "background:#dbeafe;color:#1d4ed8",
|
||
high: "background:#ffedd5;color:#c2410c",
|
||
critical: "background:#fee2e2;color:#b91c1c",
|
||
};
|
||
return map[priority] || "background:#f1f5f9;color:#475569";
|
||
}
|
||
|
||
function statusBadgeStyle(status: string): string {
|
||
const map: Record<string, string> = {
|
||
new: "background:#dbeafe;color:#1d4ed8",
|
||
in_progress: "background:#ffedd5;color:#c2410c",
|
||
waiting_for_user: "background:#fef9c3;color:#a16207",
|
||
resolved: "background:#dcfce7;color:#15803d",
|
||
closed: "background:#f1f5f9;color:#475569",
|
||
};
|
||
return map[status] || "background:#f1f5f9;color:#475569";
|
||
}
|
||
|
||
const BADGE_STYLE =
|
||
"display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600";
|
||
|
||
async function loadAllCases(): Promise<SupportCase[]> {
|
||
try {
|
||
const res = await fetch(`${API}/api/admin/support-cases`, {
|
||
headers: authHeaders(),
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) throw new Error("Failed");
|
||
const data = await res.json();
|
||
return Array.isArray(data.cases) ? data.cases : Array.isArray(data) ? data : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function loadAssignees(): Promise<AssigneeOption[]> {
|
||
try {
|
||
const params = new URLSearchParams({ page: "1", per_page: "200", sort: "joined_desc" });
|
||
const res = await fetch(`${API}/api/admin/employees?${params.toString()}`, {
|
||
headers: authHeaders(),
|
||
credentials: "include",
|
||
});
|
||
if (!res.ok) throw new Error("Failed");
|
||
const data = await res.json();
|
||
const raw = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : [];
|
||
return raw
|
||
.map((item: any) => ({
|
||
id: String(item.id ?? ""),
|
||
name: String(item.name ?? item.full_name ?? item.email ?? "Unknown"),
|
||
email: item.email ? String(item.email) : undefined,
|
||
}))
|
||
.filter((item: AssigneeOption) => Boolean(item.id));
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export default function SupportPage() {
|
||
const [activeTab, setActiveTab] = createSignal<"queue" | "create">("queue");
|
||
const [statusFilter, setStatusFilter] = createSignal<"all" | SupportCase["status"]>("all");
|
||
const [search, setSearch] = createSignal("");
|
||
const [sortBy, setSortBy] = createSignal<"newest" | "oldest" | "priority">("newest");
|
||
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
|
||
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
|
||
const [refetchKey, setRefetchKey] = createSignal(0);
|
||
|
||
const [cases] = createResource(refetchKey, loadAllCases);
|
||
const [assignees] = createResource(loadAssignees);
|
||
|
||
const refetch = () => setRefetchKey((k) => k + 1);
|
||
|
||
const filteredCases = createMemo(() => {
|
||
let all = cases() ?? [];
|
||
const q = search().toLowerCase().trim();
|
||
if (q) {
|
||
all = all.filter(
|
||
(c) =>
|
||
String(c.title || "")
|
||
.toLowerCase()
|
||
.includes(q) ||
|
||
String(c.description || "")
|
||
.toLowerCase()
|
||
.includes(q) ||
|
||
String(c.requesterName || "")
|
||
.toLowerCase()
|
||
.includes(q) ||
|
||
String(c.requesterEmail || "")
|
||
.toLowerCase()
|
||
.includes(q) ||
|
||
String(c.type || "")
|
||
.toLowerCase()
|
||
.includes(q)
|
||
);
|
||
}
|
||
const sf = statusFilter();
|
||
if (sf !== "all") all = all.filter((c) => c.status === sf);
|
||
const priorityRank: Record<SupportCase["priority"], number> = {
|
||
critical: 4,
|
||
high: 3,
|
||
medium: 2,
|
||
low: 1,
|
||
};
|
||
const sorted = [...all];
|
||
sorted.sort((a, b) => {
|
||
if (sortBy() === "oldest")
|
||
return new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime();
|
||
if (sortBy() === "priority")
|
||
return (priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0);
|
||
return new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime();
|
||
});
|
||
return sorted;
|
||
});
|
||
|
||
const stats = createMemo(() => {
|
||
const all = cases() ?? [];
|
||
return {
|
||
newCount: all.filter((c) => c.status === "new").length,
|
||
inProgressCount: all.filter((c) => c.status === "in_progress").length,
|
||
waitingCount: all.filter((c) => c.status === "waiting_for_user").length,
|
||
total: all.length,
|
||
};
|
||
});
|
||
|
||
// Create Case form state
|
||
const [fTitle, setFTitle] = createSignal("");
|
||
const [fDesc, setFDesc] = createSignal("");
|
||
const [fType, setFType] = createSignal<SupportCase["type"]>("customer_query");
|
||
const [fPriority, setFPriority] = createSignal<SupportCase["priority"]>("medium");
|
||
const [fRequesterName, setFRequesterName] = createSignal("");
|
||
const [fRequesterEmail, setFRequesterEmail] = createSignal("");
|
||
const [fAssignedTo, setFAssignedTo] = createSignal("");
|
||
const [createLoading, setCreateLoading] = createSignal(false);
|
||
const [createSuccess, setCreateSuccess] = createSignal("");
|
||
const [createError, setCreateError] = createSignal("");
|
||
|
||
const resetForm = () => {
|
||
setFTitle("");
|
||
setFDesc("");
|
||
setFType("customer_query");
|
||
setFPriority("medium");
|
||
setFRequesterName("");
|
||
setFRequesterEmail("");
|
||
setFAssignedTo("");
|
||
};
|
||
|
||
const handleCreate = async (e: Event) => {
|
||
e.preventDefault();
|
||
setCreateLoading(true);
|
||
setCreateSuccess("");
|
||
setCreateError("");
|
||
try {
|
||
const res = await fetch(`${API}/api/admin/support-cases`, {
|
||
method: "POST",
|
||
headers: authHeaders(true),
|
||
credentials: "include",
|
||
body: JSON.stringify({
|
||
title: fTitle(),
|
||
description: fDesc(),
|
||
category: fType(),
|
||
priority: fPriority(),
|
||
requesterName: fRequesterName(),
|
||
requesterEmail: fRequesterEmail(),
|
||
}),
|
||
});
|
||
if (!res.ok) {
|
||
const d = await res.json().catch(() => ({}));
|
||
throw new Error((d as any).message || "Failed to create case");
|
||
}
|
||
const created = await res.json().catch(() => ({}));
|
||
const createdId = String((created as any)?.id || "");
|
||
if (createdId && fAssignedTo()) {
|
||
await fetch(`${API}/api/admin/support-cases/${createdId}`, {
|
||
method: "PATCH",
|
||
headers: authHeaders(true),
|
||
credentials: "include",
|
||
body: JSON.stringify({ assigned_to: fAssignedTo() }),
|
||
});
|
||
}
|
||
setCreateSuccess("Case created!");
|
||
resetForm();
|
||
refetch();
|
||
setActiveTab("queue");
|
||
} catch (err: any) {
|
||
setCreateError(err.message || "Failed to create case");
|
||
} finally {
|
||
setCreateLoading(false);
|
||
}
|
||
};
|
||
|
||
const statCards = [
|
||
{ label: "New", getValue: () => stats().newCount },
|
||
{ label: "In Progress", getValue: () => stats().inProgressCount },
|
||
{ label: "Waiting", getValue: () => stats().waitingCount },
|
||
{ label: "Total", getValue: () => stats().total },
|
||
];
|
||
|
||
const exportCsv = () => {
|
||
const headers = ["Issue", "Type", "Priority", "Status", "Requester", "Updated"];
|
||
const rows = filteredCases().map((item) => [
|
||
item.title,
|
||
item.type,
|
||
item.priority,
|
||
item.status,
|
||
item.requesterEmail || item.requesterName || "—",
|
||
item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "—",
|
||
]);
|
||
const csv = [headers, ...rows]
|
||
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","))
|
||
.join("\n");
|
||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = "support-management.csv";
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
return (
|
||
<div class="w-full space-y-6 pb-8">
|
||
<div style="margin-bottom:1.5rem">
|
||
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Support Management</h1>
|
||
<p class="mt-1 text-[14px] text-[#6B7280]">Handle platform issues and customer queries</p>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
|
||
<button
|
||
type="button"
|
||
class={
|
||
activeTab() === "queue"
|
||
? "py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
|
||
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
|
||
}
|
||
onClick={() => setActiveTab("queue")}
|
||
>
|
||
Support Queue
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class={
|
||
activeTab() === "create"
|
||
? "py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
|
||
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
|
||
}
|
||
onClick={() => setActiveTab("create")}
|
||
>
|
||
Create Case
|
||
</button>
|
||
</div>
|
||
|
||
<div>
|
||
{/* Stats bar */}
|
||
<div style="display:flex;gap:12px;margin-bottom:16px">
|
||
<For each={statCards}>
|
||
{(card) => (
|
||
<div style="background:#f8f9fa;border:1px solid #e5e7eb;border-radius:8px;padding:12px 20px;text-align:center">
|
||
<div style="font-size:24px;font-weight:700">{card.getValue()}</div>
|
||
<div style="font-size:12px;color:#6b7280">{card.label}</div>
|
||
</div>
|
||
)}
|
||
</For>
|
||
</div>
|
||
|
||
{/* Support Queue Tab */}
|
||
<Show when={activeTab() === "queue"}>
|
||
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
||
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6;position:relative;z-index:20;margin-bottom:0">
|
||
<input
|
||
type="text"
|
||
placeholder="Search issues, requester, type..."
|
||
value={search()}
|
||
onInput={(e) => setSearch(e.currentTarget.value)}
|
||
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||
/>
|
||
<div style="position:relative;">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSortMenuOpen((v) => !v);
|
||
setFilterMenuOpen(false);
|
||
}}
|
||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||
>
|
||
<svg
|
||
width="13"
|
||
height="13"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path d="M7 4v13" />
|
||
<path d="m3 13 4 4 4-4" />
|
||
<path d="M17 20V7" />
|
||
<path d="m21 11-4-4-4 4" />
|
||
</svg>
|
||
Sort
|
||
</button>
|
||
<Show when={sortMenuOpen()}>
|
||
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:180px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
|
||
<For
|
||
each={
|
||
[
|
||
{ key: "newest", label: "Newest First" },
|
||
{ key: "oldest", label: "Oldest First" },
|
||
{ key: "priority", label: "Priority High-Low" },
|
||
] as { key: "newest" | "oldest" | "priority"; label: string }[]
|
||
}
|
||
>
|
||
{(item) => (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSortBy(item.key);
|
||
setSortMenuOpen(false);
|
||
}}
|
||
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === item.key ? "#FF5E13" : "#374151"};background:${sortBy() === item.key ? "#FFF1EB" : "transparent"}`}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
<div style="position:relative;">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setFilterMenuOpen((v) => !v);
|
||
setSortMenuOpen(false);
|
||
}}
|
||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||
>
|
||
<svg
|
||
width="13"
|
||
height="13"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path d="M3 5h18M6 12h12M10 19h4" />
|
||
</svg>
|
||
Filters
|
||
</button>
|
||
<Show when={filterMenuOpen()}>
|
||
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:220px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setStatusFilter("all");
|
||
setFilterMenuOpen(false);
|
||
}}
|
||
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === "all" ? "#FF5E13" : "#374151"};background:${statusFilter() === "all" ? "#FFF1EB" : "transparent"}`}
|
||
>
|
||
All statuses
|
||
</button>
|
||
<For each={STATUS_OPTIONS}>
|
||
{(s) => (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setStatusFilter(s);
|
||
setFilterMenuOpen(false);
|
||
}}
|
||
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === s ? "#FF5E13" : "#374151"};background:${statusFilter() === s ? "#FFF1EB" : "transparent"}`}
|
||
>
|
||
{formatValue(s)}
|
||
</button>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={exportCsv}
|
||
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer"
|
||
>
|
||
<svg
|
||
width="13"
|
||
height="13"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||
<polyline points="7 10 12 15 17 10" />
|
||
<line x1="12" y1="15" x2="12" y2="3" />
|
||
</svg>
|
||
Export
|
||
</button>
|
||
</div>
|
||
|
||
<div class="table-card">
|
||
<div class="overflow-x-auto">
|
||
<table class="data-table w-full text-sm">
|
||
<thead>
|
||
<tr>
|
||
<th>Issue</th>
|
||
<th>Type</th>
|
||
<th>Priority</th>
|
||
<th>Status</th>
|
||
<th>Requester</th>
|
||
<th>Updated At</th>
|
||
<th class="text-right">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<Show when={cases.loading}>
|
||
<tr>
|
||
<td colspan="7" style="text-align:center;padding:32px;color:#64748b">
|
||
Loading...
|
||
</td>
|
||
</tr>
|
||
</Show>
|
||
<Show when={!cases.loading && cases.error}>
|
||
<tr>
|
||
<td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">
|
||
Failed to load cases.
|
||
</td>
|
||
</tr>
|
||
</Show>
|
||
<Show when={!cases.loading && !cases.error && filteredCases().length === 0}>
|
||
<tr>
|
||
<td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">
|
||
No support cases found.
|
||
</td>
|
||
</tr>
|
||
</Show>
|
||
<Show when={!cases.loading && !cases.error && filteredCases().length > 0}>
|
||
<For each={filteredCases()}>
|
||
{(item) => (
|
||
<tr class="hover:bg-slate-50">
|
||
<td>
|
||
<div class="font-semibold text-slate-900">{item.title}</div>
|
||
<div style="font-size:12px;color:#64748b;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||
{item.description}
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span style={`${BADGE_STYLE};${typeBadgeStyle(item.type)}`}>
|
||
{formatValue(item.type)}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<span style={`${BADGE_STYLE};${priorityBadgeStyle(item.priority)}`}>
|
||
{formatValue(item.priority)}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<span style={`${BADGE_STYLE};${statusBadgeStyle(item.status)}`}>
|
||
{formatValue(item.status)}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<div style="font-size:13px">{item.requesterName || "—"}</div>
|
||
<div style="font-size:11px;color:#64748b">
|
||
{item.requesterEmail || ""}
|
||
</div>
|
||
</td>
|
||
<td class="text-slate-500" style="font-size:12px">
|
||
{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "—"}
|
||
</td>
|
||
<td>
|
||
<div class="flex items-center justify-end gap-1">
|
||
<A
|
||
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||
href={`/admin/support/${item.id}`}
|
||
>
|
||
View
|
||
</A>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</For>
|
||
</Show>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<Show when={!cases.loading && !cases.error && filteredCases().length > 0}>
|
||
<div style="display:flex;align-items:center;justify-content:space-between;border-top:1px solid #F3F4F6;padding:12px 20px">
|
||
<p style="font-size:13px;color:#6B7280">
|
||
Showing{" "}
|
||
<strong style="font-weight:600;color:#111827">
|
||
1–{filteredCases().length}
|
||
</strong>{" "}
|
||
of{" "}
|
||
<strong style="font-weight:600;color:#111827">{filteredCases().length}</strong>{" "}
|
||
cases
|
||
</p>
|
||
<div style="display:flex;align-items:center;gap:4px">
|
||
<button
|
||
type="button"
|
||
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"
|
||
>
|
||
‹
|
||
</button>
|
||
<button
|
||
type="button"
|
||
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;background:#FF5E13;color:white;font-size:13px;font-weight:600;border:none;cursor:pointer"
|
||
>
|
||
1
|
||
</button>
|
||
<button
|
||
type="button"
|
||
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer"
|
||
>
|
||
2
|
||
</button>
|
||
<button
|
||
type="button"
|
||
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer"
|
||
>
|
||
3
|
||
</button>
|
||
<button
|
||
type="button"
|
||
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"
|
||
>
|
||
›
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Create Case Tab */}
|
||
<Show when={activeTab() === "create"}>
|
||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6">
|
||
<h2 style="margin:0 0 6px;font-size:16px;font-weight:700;color:#1e293b">
|
||
Create Support Case
|
||
</h2>
|
||
<p style="margin:0 0 20px;font-size:13px;color:#64748b">
|
||
Create an internal support record for platform issues, customer concerns, or
|
||
compensation-related reviews.
|
||
</p>
|
||
<Show when={createSuccess()}>
|
||
<div style="background:#dcfce7;border:1px solid #86efac;border-radius:6px;padding:10px 14px;margin-bottom:14px;font-size:14px;color:#15803d;font-weight:600">
|
||
{createSuccess()}
|
||
</div>
|
||
</Show>
|
||
<Show when={createError()}>
|
||
<div
|
||
class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
|
||
style="margin-bottom:14px"
|
||
>
|
||
{createError()}
|
||
</div>
|
||
</Show>
|
||
<form onSubmit={handleCreate} style="display:flex;flex-direction:column;gap:16px">
|
||
<div class="field">
|
||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||
Title
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={fTitle()}
|
||
onInput={(e) => setFTitle(e.currentTarget.value)}
|
||
style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
|
||
/>
|
||
</div>
|
||
<div class="field">
|
||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||
Description
|
||
</label>
|
||
<textarea
|
||
required
|
||
rows="4"
|
||
value={fDesc()}
|
||
onInput={(e) => setFDesc(e.currentTarget.value)}
|
||
style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;resize:vertical;box-sizing:border-box"
|
||
/>
|
||
</div>
|
||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
<div class="field">
|
||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||
Type
|
||
</label>
|
||
<select
|
||
value={fType()}
|
||
onChange={(e) => setFType(e.currentTarget.value as SupportCase["type"])}
|
||
style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
|
||
>
|
||
<For each={TYPE_OPTIONS}>
|
||
{(t) => <option value={t}>{formatValue(t)}</option>}
|
||
</For>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||
Priority
|
||
</label>
|
||
<select
|
||
value={fPriority()}
|
||
onChange={(e) => setFPriority(e.currentTarget.value as SupportCase["priority"])}
|
||
style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
|
||
>
|
||
<For each={PRIORITY_OPTIONS}>
|
||
{(p) => <option value={p}>{formatValue(p)}</option>}
|
||
</For>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
<div class="field">
|
||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||
Requester Name
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={fRequesterName()}
|
||
onInput={(e) => setFRequesterName(e.currentTarget.value)}
|
||
style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
|
||
/>
|
||
</div>
|
||
<div class="field">
|
||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||
Requester Email
|
||
</label>
|
||
<input
|
||
type="email"
|
||
value={fRequesterEmail()}
|
||
onInput={(e) => setFRequesterEmail(e.currentTarget.value)}
|
||
style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||
Assign To (optional)
|
||
</label>
|
||
<select
|
||
value={fAssignedTo()}
|
||
onChange={(e) => setFAssignedTo(e.currentTarget.value)}
|
||
style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
|
||
>
|
||
<option value="">Unassigned</option>
|
||
<For each={assignees()}>
|
||
{(assignee) => (
|
||
<option value={assignee.id}>
|
||
{assignee.name}
|
||
{assignee.email ? ` (${assignee.email})` : ""}
|
||
</option>
|
||
)}
|
||
</For>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<button class="btn-primary" type="submit" disabled={createLoading()}>
|
||
{createLoading() ? "Creating..." : "Create Support Case"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|