diff --git a/src/app/(admin)/dashboard/ticket-board.tsx b/src/app/(admin)/dashboard/ticket-board.tsx index 1273bc1..ee574a2 100644 --- a/src/app/(admin)/dashboard/ticket-board.tsx +++ b/src/app/(admin)/dashboard/ticket-board.tsx @@ -3,17 +3,18 @@ import { useMemo, useState } from "react"; import Link from "next/link"; import { AnimatePresence, motion } from "framer-motion"; -import { Send, Globe, UserRound, Mail, MessageSquare, Plus } from "lucide-react"; +import { Send, Globe, UserRound, Mail, MessageSquare, Plus, Inbox } from "lucide-react"; import { useTicketEvents } from "@/lib/events/use-ticket-events"; import { formatRelativeTime } from "@/lib/format"; import type { TicketDTO, TicketStatus } from "@/lib/tickets/types"; import { NewTicketModal } from "./new-ticket-modal"; +import { Avatar } from "@/components/avatar"; -const COLUMNS: { status: TicketStatus; label: string }[] = [ - { status: "new", label: "Новые" }, - { status: "open", label: "Открытые" }, - { status: "pending", label: "В ожидании" }, - { status: "closed", label: "Закрытые" }, +const COLUMNS: { status: TicketStatus; label: string; empty: string }[] = [ + { status: "new", label: "Новые", empty: "Новых заявок нет" }, + { status: "open", label: "Открытые", empty: "Ничего в работе" }, + { status: "pending", label: "В ожидании", empty: "Никто не ждёт ответа" }, + { status: "closed", label: "Закрытые", empty: "Пока нечего закрывать" }, ]; const CHANNEL_ICON = { @@ -24,6 +25,15 @@ const CHANNEL_ICON = { widget: MessageSquare, } as const; +// Full literal strings — see components/avatar.tsx for why. +const CHANNEL_CHIP_CLASSES = { + telegram: "bg-info-soft text-info-soft-text", + email: "bg-accent-soft text-accent-soft-text", + widget: "bg-success-soft text-success-soft-text", + portal: "bg-warning-soft text-warning-soft-text", + manual: "bg-surface-hover text-text-muted", +} as const; + const PRIORITY_COLOR: Record = { low: "var(--text-faint)", normal: "var(--border-strong)", @@ -58,7 +68,7 @@ export function TicketBoard({ initialTickets }: { initialTickets: TicketDTO[] }) return (
-

Заявки

+

Заявки

@@ -108,16 +119,21 @@ function TicketCard({ ticket }: { ticket: TicketDTO }) { > -

{ticket.subject}

-
- - - {ticket.customerName} +

{ticket.subject}

+
+ + + {ticket.customerName} + + + + + + {formatRelativeTime(ticket.lastMessageAt)} - {formatRelativeTime(ticket.lastMessageAt)}
diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx index 990f127..ff3e057 100644 --- a/src/app/(admin)/layout.tsx +++ b/src/app/(admin)/layout.tsx @@ -3,6 +3,7 @@ import { Ticket } from "lucide-react"; import { getCurrentSession } from "@/lib/auth/session"; import { LogoutButton } from "./logout-button"; import { NavLinks } from "./nav-links"; +import { ThemeToggle } from "./theme-toggle"; export default async function AdminLayout({ children }: { children: React.ReactNode }) { const session = await getCurrentSession(); @@ -14,7 +15,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
-
+
@@ -23,6 +24,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
{session.user.name} +
diff --git a/src/app/(admin)/nav-links.tsx b/src/app/(admin)/nav-links.tsx index 74cd2c0..1fcad24 100644 --- a/src/app/(admin)/nav-links.tsx +++ b/src/app/(admin)/nav-links.tsx @@ -2,10 +2,11 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { LayoutDashboard, Send, Mail, Code2 } from "lucide-react"; +import { LayoutDashboard, BarChart3, Send, Mail, Code2 } from "lucide-react"; const links = [ { href: "/dashboard", label: "Дашборд", icon: LayoutDashboard }, + { href: "/stats", label: "Статистика", icon: BarChart3 }, { href: "/settings/telegram", label: "Telegram", icon: Send }, { href: "/settings/email", label: "Почта", icon: Mail }, { href: "/settings/widget", label: "Виджет", icon: Code2 }, diff --git a/src/app/(admin)/stats/charts.tsx b/src/app/(admin)/stats/charts.tsx new file mode 100644 index 0000000..9a29ff9 --- /dev/null +++ b/src/app/(admin)/stats/charts.tsx @@ -0,0 +1,89 @@ +// Small inline HTML/CSS charts — see the dataviz skill guidance this file +// follows: thin marks (≤24px), 4px rounded data-end / square baseline, +// direct labels (never color alone), text in text tokens (never data color). + +export function StatTile({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +const BAR_HEIGHT = 16; + +export function HorizontalBar({ + label, + value, + max, + colorClass, +}: { + label: string; + value: number; + max: number; + colorClass: string; +}) { + const pct = max > 0 ? (value / max) * 100 : 0; + + return ( +
+ {label} +
+
+
+ {value} +
+ ); +} + +export function DailyBarChart({ data }: { data: { label: string; count: number }[] }) { + const max = Math.max(...data.map((d) => d.count), 1); + + return ( +
+
+ {data.map((d, i) => ( +
+ {d.count > 0 && ( + + {d.count} + + )} +
0 ? `${Math.max((d.count / max) * 100, d.count > 0 ? 4 : 1)}%` : "1%", + borderRadius: "4px 4px 0 0", + opacity: d.count > 0 ? 1 : 0.15, + }} + /> +
+ ))} +
+
+ {data.map((d, i) => ( + + {d.label} + + ))} +
+
+ ); +} diff --git a/src/app/(admin)/stats/page.tsx b/src/app/(admin)/stats/page.tsx new file mode 100644 index 0000000..06a538f --- /dev/null +++ b/src/app/(admin)/stats/page.tsx @@ -0,0 +1,105 @@ +import { Inbox, CircleDot, Clock, CheckCircle2 } from "lucide-react"; +import { getStatsOverview } from "@/lib/tickets/service"; +import { formatDurationMinutes } from "@/lib/format"; +import { StatTile, HorizontalBar, DailyBarChart } from "./charts"; + +const STATUS_META = { + new: { label: "Новые", icon: Inbox, soft: "--accent-soft", softText: "--accent-soft-text" }, + open: { label: "Открытые", icon: CircleDot, soft: "--success-soft", softText: "--success-soft-text" }, + pending: { label: "В ожидании", icon: Clock, soft: "--warning-soft", softText: "--warning-soft-text" }, + closed: { label: "Закрытые", icon: CheckCircle2, soft: "--border", softText: "--text-muted" }, +} as const; + +const CHANNEL_META: Record = { + email: { label: "Почта", colorClass: "bg-chart-1" }, + widget: { label: "Виджет", colorClass: "bg-chart-2" }, + telegram: { label: "Telegram", colorClass: "bg-chart-3" }, + portal: { label: "Портал", colorClass: "bg-chart-4" }, + manual: { label: "Вручную", colorClass: "bg-chart-muted" }, +}; + +export default async function StatsPage() { + const stats = await getStatsOverview(); + const maxChannelCount = Math.max(...Object.values(stats.channelCounts), 1); + const maxAgentWorkload = Math.max(...stats.agentWorkload.map((a) => a.openCount), 1); + + return ( +
+

Статистика

+ +
+ + + + +
+ +
+
+

По статусам

+
+ {(Object.keys(STATUS_META) as (keyof typeof STATUS_META)[]).map((status) => { + const meta = STATUS_META[status]; + const Icon = meta.icon; + return ( +
+ + + {meta.label} + + {stats.statusCounts[status]} +
+ ); + })} +
+
+ +
+

По каналам

+
+ {Object.entries(stats.channelCounts).map(([channel, count]) => ( + + ))} +
+
+ +
+

Заявки за 14 дней

+ +
+ +
+

Загрузка агентов (открытые заявки)

+ {stats.agentWorkload.length === 0 ? ( +

Пока нет данных.

+ ) : ( +
+ {stats.agentWorkload.map((agent) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/app/(admin)/theme-toggle.tsx b/src/app/(admin)/theme-toggle.tsx new file mode 100644 index 0000000..3cc7845 --- /dev/null +++ b/src/app/(admin)/theme-toggle.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Sun, Moon } from "lucide-react"; + +function getEffectiveTheme(): "light" | "dark" { + const stored = document.documentElement.dataset.theme; + if (stored === "light" || stored === "dark") return stored; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +// Both icons always render; CSS (mirroring the same data-theme cascade the +// palette itself uses) decides which one shows — avoids a useState/useEffect +// pair just to reflect the DOM attribute the no-flash script already set. +export function ThemeToggle() { + function toggle() { + const next = getEffectiveTheme() === "dark" ? "light" : "dark"; + document.documentElement.dataset.theme = next; + localStorage.setItem("theme", next); + } + + return ( + + ); +} diff --git a/src/app/(admin)/tickets/[id]/ticket-thread.tsx b/src/app/(admin)/tickets/[id]/ticket-thread.tsx index 7a85225..5a4f74c 100644 --- a/src/app/(admin)/tickets/[id]/ticket-thread.tsx +++ b/src/app/(admin)/tickets/[id]/ticket-thread.tsx @@ -3,9 +3,10 @@ import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { AnimatePresence, motion } from "framer-motion"; -import { ArrowLeft, Send } from "lucide-react"; +import { ArrowLeft, Send, StickyNote } from "lucide-react"; import { useTicketEvents } from "@/lib/events/use-ticket-events"; import { formatRelativeTime } from "@/lib/format"; +import { Avatar } from "@/components/avatar"; import type { TicketDTO, MessageDTO, TicketStatus } from "@/lib/tickets/types"; const STATUS_LABEL: Record = { @@ -27,6 +28,7 @@ export function TicketThread({ const [ticket, setTicket] = useState(initialTicket); const [messages, setMessages] = useState(initialMessages); const [draft, setDraft] = useState(""); + const [visibility, setVisibility] = useState<"public" | "internal">("public"); const [sending, setSending] = useState(false); const bottomRef = useRef(null); @@ -49,7 +51,7 @@ export function TicketThread({ const res = await fetch(`/api/tickets/${ticket.id}/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ body: draft }), + body: JSON.stringify({ body: draft, visibility }), }); setSending(false); if (res.ok) setDraft(""); @@ -87,8 +89,11 @@ export function TicketThread({ Дашборд -

{ticket.subject}

-

{ticket.customerName}

+

{ticket.subject}

+ + + {ticket.customerName} +
@@ -127,23 +132,53 @@ export function TicketThread({
-
-