diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx
index f0b25b2..503f846 100644
--- a/dashboard/src/App.tsx
+++ b/dashboard/src/App.tsx
@@ -14,6 +14,7 @@ import Settings from "./pages/Settings";
import SystemDetails from "./pages/SystemDetails";
import Logs from "./pages/Logs";
import Transcripts from "./pages/Transcripts";
+import Tickets from "./pages/Tickets";
import Users from "./pages/Users";
function Protected({ children }: { children: React.ReactNode }) {
@@ -90,6 +91,14 @@ export default function App() {
}
/>
+
+
+
+ }
+ />
>("/api/system/transcripts"),
+ listOpenTickets: () =>
+ request<
+ ApiResponse<{
+ guildId: string;
+ tickets: {
+ ticket_id: string;
+ user_id: string;
+ subject: string;
+ description: string;
+ department: string;
+ priority: string;
+ created_at: number;
+ }[];
+ }>
+ >("/api/system/tickets/open"),
+
getTranscript: (id: string) =>
request>(`/api/system/transcripts/${id}`),
diff --git a/dashboard/src/components/app-sidebar.tsx b/dashboard/src/components/app-sidebar.tsx
index fe0e2fe..978eaa5 100644
--- a/dashboard/src/components/app-sidebar.tsx
+++ b/dashboard/src/components/app-sidebar.tsx
@@ -65,6 +65,14 @@ const sections: {
{ title: "Console", url: "/logs", icon: ComputerTerminal01Icon, permission: "logs" },
],
},
+ {
+ label: "Access & Logs",
+ items: [
+ { title: "Users", url: "/users", icon: UserMultiple02Icon, permission: "users.view" },
+ { title: "Tickets", url: "/tickets", icon: Ticket01Icon, permission: "transcripts" },
+ { title: "Transcripts", url: "/transcripts", icon: File02Icon, permission: "transcripts" },
+ ],
+ },
{
label: "Configuration",
items: [
@@ -73,14 +81,7 @@ const sections: {
{ title: "AI Assistant", url: "/configs?file=supportbot-ai", icon: AiBrain01Icon },
{ title: "Messages", url: "/configs?file=messages", icon: Message01Icon },
{ title: "Commands", url: "/configs?file=commands", icon: CommandIcon },
- ],
- },
- {
- label: "Access & Logs",
- items: [
- { title: "Users", url: "/users", icon: UserMultiple02Icon, permission: "users.view" },
- { title: "Staff Roles", url: "/configs?file=supportbot", icon: UserMultiple02Icon },
- { title: "Transcripts", url: "/transcripts", icon: File02Icon, permission: "transcripts" },
+ { title: "Staff Roles", url: "/configs?file=supportbot&tab=Roles", icon: UserMultiple02Icon },
],
},
{
diff --git a/dashboard/src/components/config/config-visual-editor.tsx b/dashboard/src/components/config/config-visual-editor.tsx
index 70b2cf9..270481e 100644
--- a/dashboard/src/components/config/config-visual-editor.tsx
+++ b/dashboard/src/components/config/config-visual-editor.tsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
+import { useSearchParams } from "react-router-dom";
import { AlertCircleIcon, FloppyDiskIcon } from "@hugeicons/core-free-icons";
import { api, maskConfigSecrets } from "@/api/client";
import { Icon } from "@/components/icon";
@@ -92,7 +93,10 @@ export function ConfigVisualEditor({ configFile }: ConfigVisualEditorProps) {
const [mode, setMode] = useState("visual");
const [rawYaml, setRawYaml] = useState("");
const [rawOriginal, setRawOriginal] = useState("");
- const [activeSectionTitle, setActiveSectionTitle] = useState(null);
+ const [searchParams] = useSearchParams();
+ const [activeSectionTitle, setActiveSectionTitle] = useState(
+ () => searchParams.get("tab") || null
+ );
const baseSchema = getSchema(configFile);
@@ -103,11 +107,17 @@ export function ConfigVisualEditor({ configFile }: ConfigVisualEditorProps) {
useEffect(() => {
if (effectiveSchema && effectiveSchema !== "commands") {
- if (!activeSectionTitle || !effectiveSchema.find(s => s.title === activeSectionTitle)) {
+ const tabParam = searchParams.get("tab");
+ if (tabParam && effectiveSchema.find(s => s.title.toLowerCase() === tabParam.toLowerCase())) {
+ const found = effectiveSchema.find(s => s.title.toLowerCase() === tabParam.toLowerCase());
+ if (found && found.title !== activeSectionTitle) {
+ setActiveSectionTitle(found.title);
+ }
+ } else if (!activeSectionTitle || !effectiveSchema.find(s => s.title === activeSectionTitle)) {
setActiveSectionTitle(effectiveSchema[0]?.title || null);
}
}
- }, [effectiveSchema, activeSectionTitle]);
+ }, [effectiveSchema, activeSectionTitle, searchParams]);
const load = useCallback(async () => {
setLoading(true);
diff --git a/dashboard/src/components/settings/dashboard-branding-settings.tsx b/dashboard/src/components/settings/dashboard-branding-settings.tsx
index 6a14f6e..06a2461 100644
--- a/dashboard/src/components/settings/dashboard-branding-settings.tsx
+++ b/dashboard/src/components/settings/dashboard-branding-settings.tsx
@@ -20,6 +20,46 @@ interface DashboardBrandingSettingsProps {
canEdit: boolean;
}
+export function DashboardBrandingDangerZone({ canEdit }: DashboardBrandingSettingsProps) {
+ const { setBrandingLocal } = useBranding();
+ const [saving, setSaving] = useState(false);
+ const [message, setMessage] = useState("");
+
+ async function resetAll() {
+ if (!canEdit) return;
+ setSaving(true);
+ setMessage("");
+ try {
+ const res = await api.resetBranding();
+ if (res.data) setBrandingLocal(res.data);
+ setMessage(res.message || "Reset to defaults.");
+ } catch (err) {
+ setMessage(err instanceof Error ? err.message : "Reset failed");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ if (!canEdit) return null;
+
+ return (
+
+
+ Reset all branding text and favicon to their original defaults.
+
+
+ {message &&
{message}
}
+
+ );
+}
+
export function DashboardBrandingSettings({ canEdit }: DashboardBrandingSettingsProps) {
const { branding, setBrandingLocal } = useBranding();
const fileRef = useRef(null);
@@ -86,18 +126,10 @@ export function DashboardBrandingSettings({ canEdit }: DashboardBrandingSettings
}
}
- async function resetAll() {
- if (!canEdit) return;
- setSaving(true);
- setMessage("");
- try {
- const res = await api.resetBranding();
- if (res.data) setBrandingLocal(res.data);
- setMessage(res.message || "Reset to defaults.");
- } catch (err) {
- setMessage(err instanceof Error ? err.message : "Reset failed");
} finally {
- setSaving(false);
+ setUploading(false);
+ }
+ }
}
}
@@ -222,41 +254,7 @@ export function DashboardBrandingSettings({ canEdit }: DashboardBrandingSettings
- {/* Danger Zone */}
- {canEdit ? (
-
-
- Danger Zone
-
-
-
-
- Reset all branding text and favicon to their original defaults.
-
-
-
-
- ) : null}
+
);
}
diff --git a/dashboard/src/pages/Overview.tsx b/dashboard/src/pages/Overview.tsx
index 30b3c52..1eaad0b 100644
--- a/dashboard/src/pages/Overview.tsx
+++ b/dashboard/src/pages/Overview.tsx
@@ -192,9 +192,9 @@ export default function Overview() {
title: "Open ticket queue",
subtitle: `${openCount} waiting`,
icon: Ticket01Icon,
- href: "/transcripts",
- status: "todo" as ModuleStatus,
- meta: "View transcripts",
+ href: "/tickets",
+ status: "done" as ModuleStatus,
+ meta: "View live tickets",
},
]
: tab === "closed"
diff --git a/dashboard/src/pages/Settings.tsx b/dashboard/src/pages/Settings.tsx
index 0b3a816..372e39e 100644
--- a/dashboard/src/pages/Settings.tsx
+++ b/dashboard/src/pages/Settings.tsx
@@ -27,7 +27,7 @@ import {
saveAccentTheme,
} from "@/lib/accent-theme";
import { normalizeHex } from "@/lib/normalize-hex";
-import { DashboardBrandingSettings } from "@/components/settings/dashboard-branding-settings";
+import { DashboardBrandingSettings, DashboardBrandingDangerZone } from "@/components/settings/dashboard-branding-settings";
import { DashboardApiSettings } from "@/components/settings/dashboard-api-settings";
export default function Settings() {
@@ -42,6 +42,7 @@ export default function Settings() {
() => getStoredAccentHex() ?? DEFAULT_ACCENT_HEX,
);
const [accentSaved, setAccentSaved] = useState("");
+ const [activeTab, setActiveTab] = useState<"appearance" | "api" | "updates">("appearance");
const loadCheck = useCallback(async () => {
setLoading(true);
@@ -88,7 +89,37 @@ export default function Settings() {
-
+
+
+
+
+ {activeTab === "appearance" && (
+
+
Dashboard appearance
@@ -100,36 +131,33 @@ export default function Settings() {
-
Accent colour
+
Accent colour
Buttons, links, and highlights — saved in this browser only.
-
-
-
-
- {
- setAccentHex(hex);
- saveAccentTheme(hex);
- setAccentSaved("Accent updated.");
- }}
- />
- setAccentHex(e.target.value)}
- onBlur={() => {
- const next = normalizeHex(accentHex, DEFAULT_ACCENT_HEX);
- setAccentHex(next);
- saveAccentTheme(next);
- setAccentSaved("Accent updated.");
- }}
- className="w-32 border-border bg-secondary/30 font-mono uppercase"
- maxLength={7}
- />
-
+
-
+
@@ -163,6 +191,20 @@ export default function Settings() {
+ {canUpdate && (
+
+
+ Danger Zone
+
+
+
+
+
+ )}
+
+ )}
+
+ {activeTab === "api" && (
API Configuration
@@ -174,7 +216,9 @@ export default function Settings() {
+ )}
+ {activeTab === "updates" && (
Software updates
@@ -273,6 +317,9 @@ export default function Settings() {
+ )}
+
+
);
}
diff --git a/dashboard/src/pages/Tickets.tsx b/dashboard/src/pages/Tickets.tsx
new file mode 100644
index 0000000..9b8b826
--- /dev/null
+++ b/dashboard/src/pages/Tickets.tsx
@@ -0,0 +1,137 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ RefreshIcon,
+ Link01Icon,
+ Clock01Icon,
+} from "@hugeicons/core-free-icons";
+import { api } from "@/api/client";
+import { Icon } from "@/components/icon";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { formatUptime } from "@/api/client";
+
+interface TicketItem {
+ ticket_id: string;
+ user_id: string;
+ subject: string;
+ description: string;
+ department: string;
+ priority: string;
+ created_at: number;
+}
+
+export default function Tickets() {
+ const [list, setList] = useState
([]);
+ const [guildId, setGuildId] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ const loadList = useCallback(async () => {
+ setLoading(true);
+ setError("");
+ try {
+ const res = await api.listOpenTickets();
+ if (res.data) {
+ setGuildId(res.data.guildId);
+ setList(res.data.tickets.sort((a, b) => b.created_at - a.created_at));
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load open tickets");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadList();
+ }, [loadList]);
+
+ const getTimeElapsed = (createdAt: number) => {
+ const seconds = Math.floor((Date.now() - createdAt) / 1000);
+ return formatUptime(seconds);
+ };
+
+ return (
+
+
+
+
Open Tickets
+
+ View live tickets currently active in Discord.
+
+
+
+
+
+
+
+ {loading ? (
+
+
+
+
+
+ ) : error ? (
+
+ ) : list.length === 0 ? (
+
+ No open tickets at the moment!
+
+ ) : (
+
+ {list.map((ticket) => (
+
+
+
+ {ticket.subject || "Untitled Ticket"}
+
+ #{ticket.ticket_id}
+
+
+
+ User ID: {ticket.user_id} • Dept: {ticket.department} • Priority: {ticket.priority}
+
+
+
+
+
+ {getTimeElapsed(ticket.created_at)}
+
+ {guildId && (
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}