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} - /> -
+
+
+ { + 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 ? ( +
+ + {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 && ( + + )} +
+
+ ))} +
+ )} +
+
+
+ ); +}