diff --git a/src/app/(admin)/dashboard/ticket-board.tsx b/src/app/(admin)/dashboard/ticket-board.tsx index ee574a2..bc78686 100644 --- a/src/app/(admin)/dashboard/ticket-board.tsx +++ b/src/app/(admin)/dashboard/ticket-board.tsx @@ -1,14 +1,15 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { AnimatePresence, motion } from "framer-motion"; -import { Send, Globe, UserRound, Mail, MessageSquare, Plus, Inbox } from "lucide-react"; +import { Send, Globe, UserRound, Mail, MessageSquare, Plus, Inbox, Search, X } 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"; +import { TAG_COLOR_CLASSES } from "@/lib/tags/colors"; const COLUMNS: { status: TicketStatus; label: string; empty: string }[] = [ { status: "new", label: "Новые", empty: "Новых заявок нет" }, @@ -44,6 +45,10 @@ const PRIORITY_COLOR: Record = { export function TicketBoard({ initialTickets }: { initialTickets: TicketDTO[] }) { const [tickets, setTickets] = useState(initialTickets); const [modalOpen, setModalOpen] = useState(false); + const [activeTagId, setActiveTagId] = useState(null); + const [query, setQuery] = useState(""); + const [searchResults, setSearchResults] = useState(null); + const [searching, setSearching] = useState(false); useTicketEvents("/api/events", (event) => { if (event.type === "ticket.created" || event.type === "ticket.updated") { @@ -54,20 +59,50 @@ export function TicketBoard({ initialTickets }: { initialTickets: TicketDTO[] }) } }); + // Search hits the server (message bodies aren't loaded client-side), + // debounced. An empty query needs no fetch — baseTickets below just falls + // back to the live SSE-backed list, so there's nothing to reset here. + useEffect(() => { + if (!query.trim()) return; + const handle = setTimeout(() => { + setSearching(true); + fetch(`/api/tickets?q=${encodeURIComponent(query)}`) + .then((res) => res.json()) + .then((data) => setSearchResults(data.tickets ?? [])) + .finally(() => setSearching(false)); + }, 300); + return () => clearTimeout(handle); + }, [query]); + + const baseTickets = query.trim() ? (searchResults ?? tickets) : tickets; + + const availableTags = useMemo(() => { + const map = new Map(); + for (const ticket of baseTickets) { + for (const tag of ticket.tags) map.set(tag.id, tag); + } + return [...map.values()].sort((a, b) => a.name.localeCompare(b.name)); + }, [baseTickets]); + + const filteredTickets = useMemo(() => { + if (!activeTagId) return baseTickets; + return baseTickets.filter((t) => t.tags.some((tag) => tag.id === activeTagId)); + }, [baseTickets, activeTagId]); + const byStatus = useMemo(() => { const grouped: Record = { new: [], open: [], pending: [], closed: [] }; - for (const ticket of tickets) { + for (const ticket of filteredTickets) { grouped[ticket.status].push(ticket); } for (const list of Object.values(grouped)) { list.sort((a, b) => b.lastMessageAt - a.lastMessageAt); } return grouped; - }, [tickets]); + }, [filteredTickets]); return (
-
+

Заявки

+
+ + setQuery(e.target.value)} + placeholder="Поиск по заявкам…" + className="w-full rounded-md border border-border bg-surface py-2 pl-9 pr-8 text-sm outline-none focus:border-accent" + /> + {query && ( + + )} +
+ + {query.trim() && searchResults && ( +

+ {searching ? "Ищем…" : `Найдено: ${searchResults.length}`} +

+ )} + + {availableTags.length > 0 && ( +
+ {availableTags.map((tag) => ( + + ))} +
+ )} +
{COLUMNS.map((col) => (
@@ -122,7 +197,16 @@ function TicketCard({ ticket }: { ticket: TicketDTO }) { className="card block p-3 shadow-sm transition-shadow hover:shadow-md" style={{ borderLeft: `3px solid ${PRIORITY_COLOR[ticket.priority]}` }} > -

{ticket.subject}

+

{ticket.subject}

+ {ticket.tags.length > 0 && ( +
+ {ticket.tags.map((tag) => ( + + {tag.name} + + ))} +
+ )}
diff --git a/src/app/(admin)/desktop-notifications.tsx b/src/app/(admin)/desktop-notifications.tsx new file mode 100644 index 0000000..0c05c0f --- /dev/null +++ b/src/app/(admin)/desktop-notifications.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useTicketEvents } from "@/lib/events/use-ticket-events"; + +/** + * No visible UI — just a permanently-mounted subscriber (in the admin + * layout, so it's alive regardless of which admin page is open) that fires + * a desktop notification for events that mean "a customer needs a + * response," while the tab is in the background. Agent-authored messages + * are deliberately skipped — that's the agent's own action, not a signal. + */ +export function DesktopNotifications() { + const router = useRouter(); + + useTicketEvents("/api/events", (event) => { + if (typeof window === "undefined" || !("Notification" in window)) return; + if (Notification.permission !== "granted" || !document.hidden) return; + + if (event.type === "ticket.created") { + const notification = new Notification("Новая заявка", { body: event.ticket.subject }); + notification.onclick = () => { + window.focus(); + router.push(`/tickets/${event.ticket.id}`); + }; + } else if (event.type === "message.created" && event.message.authorType === "customer") { + const notification = new Notification(`Сообщение от ${event.message.authorName}`, { + body: event.message.body.slice(0, 120), + }); + notification.onclick = () => { + window.focus(); + router.push(`/tickets/${event.ticketId}`); + }; + } + }); + + return null; +} diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx index 3b46997..5e47295 100644 --- a/src/app/(admin)/layout.tsx +++ b/src/app/(admin)/layout.tsx @@ -5,6 +5,8 @@ import { LogoutButton } from "./logout-button"; import { NavLinks } from "./nav-links"; import { ThemeToggle } from "./theme-toggle"; import { SettingsMenu } from "./settings-menu"; +import { NotificationToggle } from "./notification-toggle"; +import { DesktopNotifications } from "./desktop-notifications"; export default async function AdminLayout({ children }: { children: React.ReactNode }) { const session = await getCurrentSession(); @@ -24,6 +26,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
{session.user.name} + @@ -34,6 +37,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
{children}
+
); } diff --git a/src/app/(admin)/notification-toggle.tsx b/src/app/(admin)/notification-toggle.tsx new file mode 100644 index 0000000..c17e03e --- /dev/null +++ b/src/app/(admin)/notification-toggle.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useState } from "react"; +import { Bell, BellOff } from "lucide-react"; + +function initialPermission(): NotificationPermission | "unsupported" { + if (typeof window === "undefined" || !("Notification" in window)) return "unsupported"; + return Notification.permission; +} + +export function NotificationToggle() { + const [permission, setPermission] = useState(initialPermission); + + if (permission === "unsupported") return null; + + async function requestPermission() { + if (permission === "granted") return; // no API to revoke it programmatically — only the browser's own site settings can + setPermission(await Notification.requestPermission()); + } + + return ( + + ); +} diff --git a/src/app/(admin)/settings-menu.tsx b/src/app/(admin)/settings-menu.tsx index b30c307..566a5af 100644 --- a/src/app/(admin)/settings-menu.tsx +++ b/src/app/(admin)/settings-menu.tsx @@ -3,12 +3,15 @@ import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Settings, Send, Mail, Code2 } from "lucide-react"; +import { Settings, Send, Mail, Code2, MessageSquareText, Tags, UserCog } from "lucide-react"; const items = [ { href: "/settings/telegram", label: "Telegram", icon: Send }, { href: "/settings/email", label: "Почта", icon: Mail }, { href: "/settings/widget", label: "Виджет", icon: Code2 }, + { href: "/settings/canned", label: "Шаблоны ответов", icon: MessageSquareText }, + { href: "/settings/tags", label: "Теги", icon: Tags }, + { href: "/settings/account", label: "Аккаунт", icon: UserCog }, ]; export function SettingsMenu() { @@ -39,7 +42,7 @@ export function SettingsMenu() { {open && ( -
+
{items.map(({ href, label, icon: Icon }) => { const active = pathname.startsWith(href); return ( diff --git a/src/app/(admin)/settings/account/change-password-form.tsx b/src/app/(admin)/settings/account/change-password-form.tsx new file mode 100644 index 0000000..1f48911 --- /dev/null +++ b/src/app/(admin)/settings/account/change-password-form.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useState } from "react"; +import { KeyRound } from "lucide-react"; + +export function ChangePasswordForm() { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSuccess(false); + + if (newPassword !== confirmPassword) { + setError("Пароли не совпадают"); + return; + } + + setLoading(true); + const res = await fetch("/api/auth/change-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ currentPassword, newPassword }), + }); + setLoading(false); + + if (!res.ok) { + const data = await res.json().catch(() => null); + setError(data?.error ?? "Не удалось сменить пароль"); + return; + } + + setSuccess(true); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } + + return ( +
+ + + + + {error &&

{error}

} + {success && ( +

Пароль изменён

+ )} + + +
+ ); +} diff --git a/src/app/(admin)/settings/account/page.tsx b/src/app/(admin)/settings/account/page.tsx new file mode 100644 index 0000000..7301876 --- /dev/null +++ b/src/app/(admin)/settings/account/page.tsx @@ -0,0 +1,14 @@ +import { getCurrentSession } from "@/lib/auth/session"; +import { ChangePasswordForm } from "./change-password-form"; + +export default async function AccountSettingsPage() { + const session = await getCurrentSession(); + + return ( +
+

Аккаунт

+

{session?.user.email}

+ +
+ ); +} diff --git a/src/app/(admin)/settings/canned/canned-responses-manager.tsx b/src/app/(admin)/settings/canned/canned-responses-manager.tsx new file mode 100644 index 0000000..8e3aa68 --- /dev/null +++ b/src/app/(admin)/settings/canned/canned-responses-manager.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useState } from "react"; +import { Plus, Pencil, Trash2, X, Check } from "lucide-react"; + +interface CannedResponse { + id: string; + title: string; + body: string; +} + +export function CannedResponsesManager({ initialResponses }: { initialResponses: CannedResponse[] }) { + const [responses, setResponses] = useState(initialResponses); + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [editingId, setEditingId] = useState(null); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + + const res = await fetch("/api/canned-responses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, body }), + }); + + setLoading(false); + if (!res.ok) { + const data = await res.json().catch(() => null); + setError(data?.error ?? "Не удалось создать шаблон"); + return; + } + + const { response } = await res.json(); + setResponses((prev) => [...prev, response].sort((a, b) => a.title.localeCompare(b.title))); + setTitle(""); + setBody(""); + } + + async function handleDelete(id: string) { + await fetch(`/api/canned-responses/${id}`, { method: "DELETE" }); + setResponses((prev) => prev.filter((r) => r.id !== id)); + } + + async function handleUpdate(id: string, newTitle: string, newBody: string) { + const res = await fetch(`/api/canned-responses/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: newTitle, body: newBody }), + }); + if (res.ok) { + const { response } = await res.json(); + setResponses((prev) => prev.map((r) => (r.id === id ? response : r))); + setEditingId(null); + } + } + + return ( +
+
+ +