MVP-1: helpdesk core with dashboard, Telegram channel, customer portal
Next.js 16 + Drizzle/SQLite (matching ~/project-claude conventions), SSE-based realtime updates, admin dashboard with status columns, ticket thread view, Telegram long-polling integration (inbound + outbound), token-based customer portal, Docker Compose packaging. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
1 parent
888b7b82ba
commit
cf0b6cf58e
59 files changed
+12556
No files matched your search
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export function NewTicketModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [subject, setSubject] = useState("");
|
||||
const [customerName, setCustomerName] = useState("");
|
||||
const [customerEmail, setCustomerEmail] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function reset() {
|
||||
setSubject("");
|
||||
setCustomerName("");
|
||||
setCustomerEmail("");
|
||||
setBody("");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch("/api/tickets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
subject,
|
||||
customerName,
|
||||
customerEmail: customerEmail || undefined,
|
||||
body,
|
||||
}),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error ?? "Не удалось создать заявку");
|
||||
return;
|
||||
}
|
||||
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.form
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSubmit={handleSubmit}
|
||||
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="card w-full max-w-md p-5"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">Новая заявка</h2>
|
||||
<button type="button" onClick={onClose} className="text-text-muted hover:text-text">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Тема</span>
|
||||
<input
|
||||
required
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Клиент</span>
|
||||
<input
|
||||
required
|
||||
value={customerName}
|
||||
onChange={(e) => setCustomerName(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={customerEmail}
|
||||
onChange={(e) => setCustomerEmail(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="mb-4 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Сообщение</span>
|
||||
<textarea
|
||||
required
|
||||
rows={4}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
className="w-full resize-none rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
||||
{loading ? "Создаём…" : "Создать заявку"}
|
||||
</button>
|
||||
</motion.form>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { listTickets } from "@/lib/tickets/service";
|
||||
import { TicketBoard } from "./ticket-board";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const tickets = await listTickets();
|
||||
return <TicketBoard initialTickets={tickets} />;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Send, Globe, UserRound, Plus } 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";
|
||||
|
||||
const COLUMNS: { status: TicketStatus; label: string }[] = [
|
||||
{ status: "new", label: "Новые" },
|
||||
{ status: "open", label: "Открытые" },
|
||||
{ status: "pending", label: "В ожидании" },
|
||||
{ status: "closed", label: "Закрытые" },
|
||||
];
|
||||
|
||||
const CHANNEL_ICON = { telegram: Send, portal: Globe, manual: UserRound } as const;
|
||||
|
||||
const PRIORITY_COLOR: Record<TicketDTO["priority"], string> = {
|
||||
low: "var(--text-faint)",
|
||||
normal: "var(--border-strong)",
|
||||
high: "var(--warning)",
|
||||
urgent: "var(--danger)",
|
||||
};
|
||||
|
||||
export function TicketBoard({ initialTickets }: { initialTickets: TicketDTO[] }) {
|
||||
const [tickets, setTickets] = useState<TicketDTO[]>(initialTickets);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
useTicketEvents("/api/events", (event) => {
|
||||
if (event.type === "ticket.created" || event.type === "ticket.updated") {
|
||||
setTickets((prev) => {
|
||||
const withoutIt = prev.filter((t) => t.id !== event.ticket.id);
|
||||
return [event.ticket, ...withoutIt];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const byStatus = useMemo(() => {
|
||||
const grouped: Record<TicketStatus, TicketDTO[]> = { new: [], open: [], pending: [], closed: [] };
|
||||
for (const ticket of tickets) {
|
||||
grouped[ticket.status].push(ticket);
|
||||
}
|
||||
for (const list of Object.values(grouped)) {
|
||||
list.sort((a, b) => b.lastMessageAt - a.lastMessageAt);
|
||||
}
|
||||
return grouped;
|
||||
}, [tickets]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold tracking-tight">Заявки</h1>
|
||||
<button className="btn btn-primary" onClick={() => setModalOpen(true)}>
|
||||
<Plus size={16} />
|
||||
Новая заявка
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{COLUMNS.map((col) => (
|
||||
<div key={col.status} className="min-w-0">
|
||||
<div className="mb-2 flex items-center gap-2 px-1">
|
||||
<h2 className="text-sm font-semibold text-text-muted">{col.label}</h2>
|
||||
<span className="rounded-full bg-surface-hover px-1.5 text-xs text-text-faint">
|
||||
{byStatus[col.status].length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<AnimatePresence initial={false}>
|
||||
{byStatus[col.status].map((ticket) => (
|
||||
<TicketCard key={ticket.id} ticket={ticket} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{byStatus[col.status].length === 0 && (
|
||||
<p className="rounded-md border border-dashed border-border px-3 py-6 text-center text-xs text-text-faint">
|
||||
Пусто
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<NewTicketModal open={modalOpen} onClose={() => setModalOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketCard({ ticket }: { ticket: TicketDTO }) {
|
||||
const Icon = CHANNEL_ICON[ticket.channel];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.97 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.97 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
>
|
||||
<Link
|
||||
href={`/tickets/${ticket.id}`}
|
||||
className="card block p-3 transition-shadow hover:shadow-md"
|
||||
style={{ borderLeft: `3px solid ${PRIORITY_COLOR[ticket.priority]}` }}
|
||||
>
|
||||
<p className="mb-1.5 line-clamp-2 text-sm font-medium">{ticket.subject}</p>
|
||||
<div className="flex items-center justify-between text-xs text-text-muted">
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<Icon size={12} />
|
||||
{ticket.customerName}
|
||||
</span>
|
||||
<span className="shrink-0 text-text-faint">{formatRelativeTime(ticket.lastMessageAt)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Ticket } from "lucide-react";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { LogoutButton } from "./logout-button";
|
||||
import { NavLinks } from "./nav-links";
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await getCurrentSession();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-bg">
|
||||
<header className="border-b border-border bg-surface">
|
||||
<div className="mx-auto flex max-w-7xl items-center gap-6 px-6 py-3">
|
||||
<div className="flex items-center gap-2 font-bold tracking-tight">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-accent text-white">
|
||||
<Ticket size={15} strokeWidth={2.5} />
|
||||
</div>
|
||||
top-tickets
|
||||
</div>
|
||||
<NavLinks />
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<span className="text-sm text-text-muted">{session.user.name}</span>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto w-full max-w-7xl flex-1 px-6 py-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogOut } from "lucide-react";
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={handleLogout} className="btn btn-ghost" title="Выйти">
|
||||
<LogOut size={15} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LayoutDashboard, Settings } from "lucide-react";
|
||||
|
||||
const links = [
|
||||
{ href: "/dashboard", label: "Дашборд", icon: LayoutDashboard },
|
||||
{ href: "/settings/telegram", label: "Telegram", icon: Settings },
|
||||
];
|
||||
|
||||
export function NavLinks() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1">
|
||||
{links.map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
active ? "bg-accent-soft text-accent-soft-text" : "text-text-muted hover:bg-surface-hover hover:text-text"
|
||||
}`}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getTelegramStatus } from "@/lib/telegram/bot";
|
||||
import { TelegramSettingsForm } from "./telegram-settings-form";
|
||||
|
||||
export default async function TelegramSettingsPage() {
|
||||
const status = await getTelegramStatus();
|
||||
return (
|
||||
<div className="max-w-lg">
|
||||
<h1 className="mb-1 text-xl font-bold tracking-tight">Telegram</h1>
|
||||
<p className="mb-6 text-sm text-text-muted">
|
||||
Подключите бота — личные сообщения и сообщения из групп, куда он добавлен, будут превращаться в заявки.
|
||||
</p>
|
||||
<TelegramSettingsForm initialStatus={status} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
interface Status {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
botUsername: string | null;
|
||||
verifiedAt: number | null;
|
||||
}
|
||||
|
||||
export function TelegramSettingsForm({ initialStatus }: { initialStatus: Status }) {
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
const [token, setToken] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleConnect(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch("/api/telegram/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ botToken: token }),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error ?? "Не удалось подключить бота");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setStatus({ configured: true, enabled: true, botUsername: data.botUsername, verifiedAt: Date.now() });
|
||||
setToken("");
|
||||
}
|
||||
|
||||
async function handleDisable() {
|
||||
setLoading(true);
|
||||
await fetch("/api/telegram/config", { method: "DELETE" });
|
||||
setLoading(false);
|
||||
setStatus((s) => ({ ...s, enabled: false }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="mb-4 flex items-center gap-2 text-sm">
|
||||
{status.enabled ? (
|
||||
<>
|
||||
<CheckCircle2 size={16} className="text-success" />
|
||||
<span>
|
||||
Подключено{status.botUsername ? `: @${status.botUsername}` : ""}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle size={16} className="text-text-faint" />
|
||||
<span className="text-text-muted">Бот не подключён</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleConnect}>
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Bot API token</span>
|
||||
<input
|
||||
required
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="123456:ABC-DEF..."
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={loading || !token} className="btn btn-primary">
|
||||
{status.configured ? "Обновить токен" : "Подключить"}
|
||||
</button>
|
||||
{status.enabled && (
|
||||
<button type="button" onClick={handleDisable} disabled={loading} className="btn btn-ghost">
|
||||
Отключить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-xs text-text-faint">
|
||||
Чтобы бот видел все сообщения в группе (а не только те, где его упомянули), отключите Group Privacy Mode
|
||||
через @BotFather → /setprivacy → Disable.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTicketWithMessages, listAgents } from "@/lib/tickets/service";
|
||||
import { TicketThread } from "./ticket-thread";
|
||||
|
||||
export default async function TicketPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const result = await getTicketWithMessages(id);
|
||||
if (!result) notFound();
|
||||
|
||||
const agents = await listAgents();
|
||||
|
||||
return (
|
||||
<TicketThread
|
||||
ticket={result.ticket}
|
||||
initialMessages={result.messages}
|
||||
agents={agents.map((a) => ({ id: a.id, name: a.name }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowLeft, Send } from "lucide-react";
|
||||
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { TicketDTO, MessageDTO, TicketStatus } from "@/lib/tickets/types";
|
||||
|
||||
const STATUS_LABEL: Record<TicketStatus, string> = {
|
||||
new: "Новая",
|
||||
open: "Открыта",
|
||||
pending: "В ожидании",
|
||||
closed: "Закрыта",
|
||||
};
|
||||
|
||||
export function TicketThread({
|
||||
ticket: initialTicket,
|
||||
initialMessages,
|
||||
agents,
|
||||
}: {
|
||||
ticket: TicketDTO;
|
||||
initialMessages: MessageDTO[];
|
||||
agents: { id: string; name: string }[];
|
||||
}) {
|
||||
const [ticket, setTicket] = useState(initialTicket);
|
||||
const [messages, setMessages] = useState(initialMessages);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useTicketEvents("/api/events", (event) => {
|
||||
if (event.type === "ticket.updated" && event.ticket.id === ticket.id) {
|
||||
setTicket(event.ticket);
|
||||
}
|
||||
if (event.type === "message.created" && event.ticketId === ticket.id) {
|
||||
setMessages((prev) => (prev.some((m) => m.id === event.message.id) ? prev : [...prev, event.message]));
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages.length]);
|
||||
|
||||
async function sendReply() {
|
||||
if (!draft.trim()) return;
|
||||
setSending(true);
|
||||
const res = await fetch(`/api/tickets/${ticket.id}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body: draft }),
|
||||
});
|
||||
setSending(false);
|
||||
if (res.ok) setDraft("");
|
||||
}
|
||||
|
||||
async function updateStatus(status: TicketStatus) {
|
||||
const res = await fetch(`/api/tickets/${ticket.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const { ticket: updated } = await res.json();
|
||||
setTicket(updated);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAssignee(assigneeId: string) {
|
||||
const res = await fetch(`/api/tickets/${ticket.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ assigneeId: assigneeId || null }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const { ticket: updated } = await res.json();
|
||||
setTicket(updated);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-6.5rem)] flex-col">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<Link href="/dashboard" className="mb-1 flex items-center gap-1 text-xs text-text-muted hover:text-text">
|
||||
<ArrowLeft size={13} />
|
||||
Дашборд
|
||||
</Link>
|
||||
<h1 className="truncate text-lg font-bold">{ticket.subject}</h1>
|
||||
<p className="text-sm text-text-muted">{ticket.customerName}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<select
|
||||
value={ticket.status}
|
||||
onChange={(e) => updateStatus(e.target.value as TicketStatus)}
|
||||
className="rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||
>
|
||||
{Object.entries(STATUS_LABEL).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={ticket.assigneeId ?? ""}
|
||||
onChange={(e) => updateAssignee(e.target.value)}
|
||||
className="rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">Не назначено</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card flex-1 overflow-y-auto p-4">
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendReply();
|
||||
}
|
||||
}}
|
||||
rows={2}
|
||||
placeholder="Ответить клиенту…"
|
||||
className="flex-1 resize-none rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
<button onClick={sendReply} disabled={sending || !draft.trim()} className="btn btn-primary self-end">
|
||||
<Send size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: MessageDTO }) {
|
||||
const isAgent = message.authorType === "agent";
|
||||
const isSystem = message.authorType === "system";
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<p className="my-2 text-center text-xs text-text-faint">{message.body}</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className={`mb-3 flex ${isAgent ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${isAgent ? "bg-accent text-white" : "bg-surface-hover"}`}>
|
||||
<p className="mb-1 whitespace-pre-wrap">{message.body}</p>
|
||||
<p className={`text-[11px] ${isAgent ? "text-white/70" : "text-text-faint"}`}>
|
||||
{message.authorName} · {formatRelativeTime(message.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { Ticket, LogIn } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error ?? "Не удалось войти");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-bg px-4">
|
||||
<motion.form
|
||||
onSubmit={handleSubmit}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
className="card w-full max-w-sm p-8"
|
||||
>
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white">
|
||||
<Ticket size={18} strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight">top-tickets</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mb-1 text-xl font-semibold">Вход для агентов</h1>
|
||||
<p className="mb-6 text-sm text-text-muted">Войдите, чтобы открыть дашборд заявок.</p>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Email</span>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-5 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
||||
<LogIn size={16} />
|
||||
{loading ? "Входим…" : "Войти"}
|
||||
</button>
|
||||
</motion.form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Ticket } from "lucide-react";
|
||||
|
||||
export default function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-bg">
|
||||
<header className="border-b border-border bg-surface">
|
||||
<div className="mx-auto flex max-w-2xl items-center gap-2 px-6 py-3 font-bold tracking-tight">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-accent text-white">
|
||||
<Ticket size={15} strokeWidth={2.5} />
|
||||
</div>
|
||||
Поддержка
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto w-full max-w-2xl flex-1 px-6 py-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getCustomerByPortalToken, getTicketForCustomer } from "@/lib/tickets/service";
|
||||
import { PortalThread } from "./portal-thread";
|
||||
|
||||
export default async function PortalTicketPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string; ticketId: string }>;
|
||||
}) {
|
||||
const { token, ticketId } = await params;
|
||||
const customer = await getCustomerByPortalToken(token);
|
||||
if (!customer) notFound();
|
||||
|
||||
const result = await getTicketForCustomer(customer.id, ticketId);
|
||||
if (!result) notFound();
|
||||
|
||||
return (
|
||||
<PortalThread
|
||||
token={token}
|
||||
ticket={result.ticket}
|
||||
initialMessages={result.messages}
|
||||
customerName={customer.displayName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowLeft, Send } from "lucide-react";
|
||||
import { useTicketEvents } from "@/lib/events/use-ticket-events";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { TicketDTO, MessageDTO } from "@/lib/tickets/types";
|
||||
|
||||
export function PortalThread({
|
||||
token,
|
||||
ticket,
|
||||
initialMessages,
|
||||
customerName,
|
||||
}: {
|
||||
token: string;
|
||||
ticket: TicketDTO;
|
||||
initialMessages: MessageDTO[];
|
||||
customerName: string;
|
||||
}) {
|
||||
const [messages, setMessages] = useState(initialMessages);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useTicketEvents(`/api/portal/events?token=${encodeURIComponent(token)}`, (event) => {
|
||||
if (event.type === "message.created" && event.ticketId === ticket.id) {
|
||||
setMessages((prev) => (prev.some((m) => m.id === event.message.id) ? prev : [...prev, event.message]));
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages.length]);
|
||||
|
||||
async function sendReply() {
|
||||
if (!draft.trim()) return;
|
||||
setSending(true);
|
||||
const res = await fetch(`/api/portal/tickets/${ticket.id}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, body: draft }),
|
||||
});
|
||||
setSending(false);
|
||||
if (res.ok) setDraft("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col">
|
||||
<Link href={`/t/${token}`} className="mb-3 flex items-center gap-1 text-xs text-text-muted hover:text-text">
|
||||
<ArrowLeft size={13} />
|
||||
Все обращения
|
||||
</Link>
|
||||
<h1 className="mb-4 text-lg font-bold">{ticket.subject}</h1>
|
||||
|
||||
<div className="card flex-1 overflow-y-auto p-4">
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} isMine={message.authorType === "customer"} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendReply();
|
||||
}
|
||||
}}
|
||||
rows={2}
|
||||
placeholder={`Ответить как ${customerName}…`}
|
||||
className="flex-1 resize-none rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
<button onClick={sendReply} disabled={sending || !draft.trim()} className="btn btn-primary self-end">
|
||||
<Send size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message, isMine }: { message: MessageDTO; isMine: boolean }) {
|
||||
if (message.authorType === "system") {
|
||||
return <p className="my-2 text-center text-xs text-text-faint">{message.body}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className={`mb-3 flex ${isMine ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${isMine ? "bg-accent text-white" : "bg-surface-hover"}`}>
|
||||
<p className="mb-1 whitespace-pre-wrap">{message.body}</p>
|
||||
<p className={`text-[11px] ${isMine ? "text-white/70" : "text-text-faint"}`}>
|
||||
{message.authorName} · {formatRelativeTime(message.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getCustomerByPortalToken, listTicketsForCustomer } from "@/lib/tickets/service";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
new: "Новая",
|
||||
open: "Открыта",
|
||||
pending: "В ожидании",
|
||||
closed: "Закрыта",
|
||||
};
|
||||
|
||||
export default async function PortalTicketListPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
const customer = await getCustomerByPortalToken(token);
|
||||
if (!customer) notFound();
|
||||
|
||||
const tickets = await listTicketsForCustomer(customer.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-1 text-xl font-bold tracking-tight">Здравствуйте, {customer.displayName}</h1>
|
||||
<p className="mb-6 text-sm text-text-muted">Ваши обращения в поддержку.</p>
|
||||
|
||||
{tickets.length === 0 && <p className="text-sm text-text-muted">Заявок пока нет.</p>}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{tickets.map((ticket) => (
|
||||
<Link key={ticket.id} href={`/t/${token}/${ticket.id}`} className="card block p-4 hover:shadow-md">
|
||||
<div className="mb-1 flex items-center justify-between gap-3">
|
||||
<p className="truncate text-sm font-medium">{ticket.subject}</p>
|
||||
<span className={`badge badge-${ticket.status}`}>{STATUS_LABEL[ticket.status]}</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-faint">{formatRelativeTime(ticket.lastMessageAt)}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
// Simple in-memory rate limit — good enough at MVP scale, resets on restart.
|
||||
const attempts = new Map<string, { count: number; resetAt: number }>();
|
||||
const MAX_ATTEMPTS = 10;
|
||||
const WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
function isRateLimited(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = attempts.get(key);
|
||||
if (!entry || entry.resetAt < now) {
|
||||
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count > MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = loginSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const email = parsed.data.email.toLowerCase().trim();
|
||||
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
|
||||
if (isRateLimited(`${ip}:${email}`)) {
|
||||
return NextResponse.json({ error: "Too many attempts — try again later" }, { status: 429 });
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: (u, { eq }) => eq(u.email, email),
|
||||
});
|
||||
|
||||
// Always run verifyPassword (even against a placeholder hash) so the
|
||||
// response timing doesn't reveal whether the email exists.
|
||||
const ok = await verifyPassword(
|
||||
user?.passwordHash ??
|
||||
"$argon2id$v=19$m=65536,t=3,p=4$00000000000000000000000000$0000000000000000000000000000000000000000000000000000000000000000",
|
||||
parsed.data.password,
|
||||
);
|
||||
|
||||
if (!user || !ok) {
|
||||
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = await createSession(user.id);
|
||||
await setSessionCookie(token);
|
||||
|
||||
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name, role: user.role } });
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { destroySessionToken, getSessionToken, clearSessionCookie } from "@/lib/auth/session";
|
||||
|
||||
export async function POST() {
|
||||
const token = await getSessionToken();
|
||||
if (token) {
|
||||
await destroySessionToken(token);
|
||||
}
|
||||
await clearSessionCookie();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { createEventStream } from "@/lib/events/sse";
|
||||
|
||||
/** Realtime feed for the admin/agent dashboard — sees every ticket event. */
|
||||
export async function GET() {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
return createEventStream();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCustomerByPortalToken } from "@/lib/tickets/service";
|
||||
import { createEventStream } from "@/lib/events/sse";
|
||||
import { ticketEventCustomerId } from "@/lib/events/bus";
|
||||
|
||||
/**
|
||||
* Realtime feed for a customer's portal — scoped to their own tickets only.
|
||||
* EventSource can't send custom headers, so the token travels as a query param.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const token = new URL(request.url).searchParams.get("token");
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "Missing token" }, { status: 400 });
|
||||
}
|
||||
|
||||
const customer = await getCustomerByPortalToken(token);
|
||||
if (!customer) {
|
||||
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
|
||||
}
|
||||
|
||||
return createEventStream((event) => ticketEventCustomerId(event) === customer.id);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getCustomerByPortalToken, appendCustomerReply } from "@/lib/tickets/service";
|
||||
|
||||
const schema = z.object({
|
||||
token: z.string().min(1),
|
||||
body: z.string().min(1).max(10_000),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ ticketId: string }> },
|
||||
) {
|
||||
const { ticketId } = await params;
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const customer = await getCustomerByPortalToken(parsed.data.token);
|
||||
if (!customer) {
|
||||
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await appendCustomerReply({
|
||||
ticketId,
|
||||
customerId: customer.id,
|
||||
authorName: customer.displayName,
|
||||
body: parsed.data.body,
|
||||
});
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { configureTelegramBot, disableTelegramBot, getTelegramStatus } from "@/lib/telegram/bot";
|
||||
|
||||
export async function GET() {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
return NextResponse.json(await getTelegramStatus());
|
||||
}
|
||||
|
||||
const configureSchema = z.object({ botToken: z.string().min(1) });
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = configureSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { username } = await configureTelegramBot(parsed.data.botToken);
|
||||
return NextResponse.json({ ok: true, botUsername: username });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Telegram rejected this token — check it and try again" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
await disableTelegramBot();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { recordAgentReply } from "@/lib/tickets/service";
|
||||
import { sendTelegramMessage } from "@/lib/telegram/bot";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { customers } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const messageSchema = z.object({
|
||||
body: z.string().min(1).max(10_000),
|
||||
});
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = messageSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await recordAgentReply({
|
||||
ticketId: id,
|
||||
agentId: session.user.id,
|
||||
agentName: session.user.name,
|
||||
body: parsed.data.body,
|
||||
});
|
||||
|
||||
if (result.ticket.channel === "telegram") {
|
||||
const customer = await db.query.customers.findFirst({
|
||||
where: eq(customers.id, result.ticket.customerId),
|
||||
});
|
||||
if (customer?.telegramChatId) {
|
||||
await sendTelegramMessage(customer.telegramChatId, parsed.data.body);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { getTicketWithMessages, setTicketStatus, assignTicket } from "@/lib/tickets/service";
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const { id } = await params;
|
||||
const result = await getTicketWithMessages(id);
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
const patchSchema = z.object({
|
||||
status: z.enum(["new", "open", "pending", "closed"]).optional(),
|
||||
assigneeId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
let ticket = null;
|
||||
if (parsed.data.status) {
|
||||
ticket = await setTicketStatus(id, parsed.data.status);
|
||||
}
|
||||
if (parsed.data.assigneeId !== undefined) {
|
||||
ticket = await assignTicket(id, parsed.data.assigneeId);
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ ticket });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/auth/require";
|
||||
import { listTickets, createManualTicket } from "@/lib/tickets/service";
|
||||
|
||||
export async function GET() {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const tickets = await listTickets();
|
||||
return NextResponse.json({ tickets });
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
subject: z.string().min(1).max(200),
|
||||
body: z.string().min(1).max(10_000),
|
||||
customerName: z.string().min(1).max(200),
|
||||
customerEmail: z.string().email().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session, response } = await requireSession();
|
||||
if (!session) return response;
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await createManualTicket(parsed.data);
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: var(--font-mono), ui-monospace, monospace;
|
||||
|
||||
--color-bg: var(--bg);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-hover: var(--surface-hover);
|
||||
--color-border: var(--border);
|
||||
--color-border-strong: var(--border-strong);
|
||||
--color-text: var(--text);
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-text-faint: var(--text-faint);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
--color-accent-soft-text: var(--accent-soft-text);
|
||||
|
||||
--color-danger: var(--danger);
|
||||
--color-danger-soft: var(--danger-soft);
|
||||
--color-danger-soft-text: var(--danger-soft-text);
|
||||
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-soft: var(--warning-soft);
|
||||
--color-warning-soft-text: var(--warning-soft-text);
|
||||
|
||||
--color-success: var(--success);
|
||||
--color-success-soft: var(--success-soft);
|
||||
--color-success-soft-text: var(--success-soft-text);
|
||||
|
||||
--radius-sm: var(--radius-sm-val);
|
||||
--radius-md: var(--radius-md-val);
|
||||
--radius-lg: var(--radius-lg-val);
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #fafaf9;
|
||||
--surface: #ffffff;
|
||||
--surface-hover: #f6f5ff;
|
||||
--border: #e8e6f5;
|
||||
--border-strong: #d6d1ee;
|
||||
--text: #191524;
|
||||
--text-muted: #6c6685;
|
||||
--text-faint: #a29cc0;
|
||||
|
||||
--accent: #7c3aed;
|
||||
--accent-hover: #6d28d9;
|
||||
--accent-soft: #f3edff;
|
||||
--accent-soft-text: #6d28d9;
|
||||
|
||||
--danger: #dc2626;
|
||||
--danger-soft: #fef2f2;
|
||||
--danger-soft-text: #b91c1c;
|
||||
|
||||
--warning: #d97706;
|
||||
--warning-soft: #fffbeb;
|
||||
--warning-soft-text: #b45309;
|
||||
|
||||
--success: #0d9488;
|
||||
--success-soft: #ecfdf9;
|
||||
--success-soft-text: #0f766e;
|
||||
|
||||
--radius-sm-val: 8px;
|
||||
--radius-md-val: 12px;
|
||||
--radius-lg-val: 18px;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #0f0d17;
|
||||
--surface: #17141f;
|
||||
--surface-hover: #1e1a2b;
|
||||
--border: #2b2438;
|
||||
--border-strong: #3a3150;
|
||||
--text: #f1eefb;
|
||||
--text-muted: #a79fc4;
|
||||
--text-faint: #6f6790;
|
||||
|
||||
--accent: #a78bfa;
|
||||
--accent-hover: #c4b5fd;
|
||||
--accent-soft: #241b3d;
|
||||
--accent-soft-text: #c4b5fd;
|
||||
|
||||
--danger: #f87171;
|
||||
--danger-soft: #2a1517;
|
||||
--danger-soft-text: #fca5a5;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-soft: #2a2110;
|
||||
--warning-soft-text: #fcd34d;
|
||||
|
||||
--success: #2dd4bf;
|
||||
--success-soft: #0f2320;
|
||||
--success-soft-text: #5eead4;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0f0d17;
|
||||
--surface: #17141f;
|
||||
--surface-hover: #1e1a2b;
|
||||
--border: #2b2438;
|
||||
--border-strong: #3a3150;
|
||||
--text: #f1eefb;
|
||||
--text-muted: #a79fc4;
|
||||
--text-faint: #6f6790;
|
||||
|
||||
--accent: #a78bfa;
|
||||
--accent-hover: #c4b5fd;
|
||||
--accent-soft: #241b3d;
|
||||
--accent-soft-text: #c4b5fd;
|
||||
|
||||
--danger: #f87171;
|
||||
--danger-soft: #2a1517;
|
||||
--danger-soft-text: #fca5a5;
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-soft: #2a2110;
|
||||
--warning-soft-text: #fcd34d;
|
||||
|
||||
--success: #2dd4bf;
|
||||
--success-soft: #0f2320;
|
||||
--success-soft-text: #5eead4;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-soft-text);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease, transform 100ms ease;
|
||||
}
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.badge-new {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-soft-text);
|
||||
}
|
||||
.badge-open {
|
||||
background: var(--success-soft);
|
||||
color: var(--success-soft-text);
|
||||
}
|
||||
.badge-pending {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-soft-text);
|
||||
}
|
||||
.badge-closed {
|
||||
background: var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Golos_Text, JetBrains_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const sans = Golos_Text({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin", "cyrillic"],
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
variable: "--font-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "top-tickets",
|
||||
description: "Helpdesk for support@top-sysops.ru, Telegram, and the web.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="ru" className={`${sans.variable} ${mono.variable}`} suppressHydrationWarning>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
|
||||
export default async function RootPage() {
|
||||
const session = await getCurrentSession();
|
||||
redirect(session ? "/dashboard" : "/login");
|
||||
}
|
||||
Reference in new issue
Block a user