Dashboard tag filter now covers every tag, not just ones already in use; add a default tag

The dashboard's tag filter chips were derived from tags already attached to
a currently-loaded ticket — a brand-new tag (e.g. VIP was the only one ever
used) had nothing to filter by until someone happened to apply it. The
filter bar now lists every tag defined in Settings, fetched server-side
alongside the ticket list.

Also added a "default tag" concept: mark one tag as default in Settings
(star icon), and it's auto-attached to every new ticket the moment it's
created, across every channel (email/telegram/widget/manual) — matches the
request to have "В порядке очереди" applied by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-19 08:09:14 +00:00
1 parent 5af360fd15
commit b11ec711c5
10 files changed
+1139 -15

No files matched your search

+3 -3
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { listTickets } from "@/lib/tickets/service";
import { listTickets, listTags } from "@/lib/tickets/service";
import { TicketBoard } from "./ticket-board";
export default async function DashboardPage() {
@@ -8,6 +8,6 @@ export default async function DashboardPage() {
if (!session) redirect("/login");
const currentUser = { id: session.user.id, role: session.user.role };
const tickets = await listTickets(undefined, currentUser);
return <TicketBoard initialTickets={tickets} currentUser={currentUser} />;
const [tickets, tags] = await Promise.all([listTickets(undefined, currentUser), listTags()]);
return <TicketBoard initialTickets={tickets} allTags={tags} currentUser={currentUser} />;
}
+8 -8
View File
@@ -6,7 +6,7 @@ import { AnimatePresence, motion } from "framer-motion";
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 type { TicketDTO, TicketStatus, TagDTO } from "@/lib/tickets/types";
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
import { NewTicketModal } from "./new-ticket-modal";
import { Avatar } from "@/components/avatar";
@@ -45,9 +45,11 @@ const PRIORITY_COLOR: Record<TicketDTO["priority"], string> = {
export function TicketBoard({
initialTickets,
allTags,
currentUser,
}: {
initialTickets: TicketDTO[];
allTags: TagDTO[];
currentUser: { id: string; role: "admin" | "agent" };
}) {
const [tickets, setTickets] = useState<TicketDTO[]>(initialTickets);
@@ -100,13 +102,11 @@ export function TicketBoard({
const baseTickets = query.trim() ? (searchResults ?? tickets) : tickets;
const availableTags = useMemo(() => {
const map = new Map<string, TicketDTO["tags"][number]>();
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]);
// Every tag that exists, not just ones already applied to a currently
// loaded ticket — otherwise a freshly created tag has nothing to filter
// by until someone happens to tag a ticket with it. allTags is already
// name-sorted server-side (listTags orders by tagsTable.name).
const availableTags = allTags;
const filteredTickets = useMemo(() => {
if (!activeTagId) return baseTickets;
+24 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Plus, Star, Trash2 } from "lucide-react";
import { TAG_COLOR_CLASSES, TAG_COLORS, TAG_COLOR_LABELS } from "@/lib/tags/colors";
import type { TagDTO, TagColor } from "@/lib/tickets/types";
@@ -40,6 +40,18 @@ export function TagsManager({ initialTags }: { initialTags: TagDTO[] }) {
setTags((prev) => prev.filter((t) => t.id !== id));
}
async function handleToggleDefault(tag: TagDTO) {
const nextIsDefault = !tag.isDefault;
const res = await fetch(`/api/tags/${tag.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isDefault: nextIsDefault }),
});
if (!res.ok) return;
const { tags: updated } = await res.json();
setTags(updated);
}
return (
<div className="flex flex-col gap-4">
<form onSubmit={handleCreate} className="card flex flex-col gap-3 p-4 sm:flex-row sm:items-end">
@@ -77,12 +89,23 @@ export function TagsManager({ initialTags }: { initialTags: TagDTO[] }) {
{tags.length === 0 && <p className="text-sm text-text-muted">Тегов пока нет.</p>}
<p className="text-xs text-text-faint">
Тег по умолчанию отмечается звёздочкой — он автоматически ставится на каждую новую заявку.
</p>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={tag.id}
className={`flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium ${TAG_COLOR_CLASSES[tag.color]}`}
>
<button
onClick={() => handleToggleDefault(tag)}
className="opacity-60 hover:opacity-100"
title={tag.isDefault ? "Убрать как тег по умолчанию" : "Сделать тегом по умолчанию"}
>
<Star size={12} fill={tag.isDefault ? "currentColor" : "none"} />
</button>
{tag.name}
<button onClick={() => handleDelete(tag.id)} className="opacity-60 hover:opacity-100">
<Trash2 size={12} />
+18 -1
View File
@@ -1,8 +1,25 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { deleteTag } from "@/lib/tickets/service";
import { deleteTag, setDefaultTag } from "@/lib/tickets/service";
const patchSchema = z.object({ isDefault: z.boolean() });
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 parsed = patchSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const tags = await setDefaultTag(parsed.data.isDefault ? id : null);
return NextResponse.json({ tags });
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
@@ -0,0 +1 @@
ALTER TABLE `tags` ADD `is_default` integer DEFAULT false NOT NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -57,6 +57,13 @@
"when": 1787124609684,
"tag": "0007_neat_archangel",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1787126651618,
"tag": "0008_quick_ultragirl",
"breakpoints": true
}
]
}
+4
View File
@@ -205,6 +205,10 @@ export const tags = sqliteTable("tags", {
color: text("color", { enum: ["accent", "success", "warning", "info", "rose", "danger"] })
.notNull()
.default("accent"),
// At most one tag has this set (enforced in service.ts's setDefaultTag,
// not at the schema level) — auto-attached to every ticket the moment
// it's created, across every channel (email/telegram/widget/manual).
isDefault: integer("is_default", { mode: "boolean" }).notNull().default(false),
createdAt: timestamps.createdAt,
});
+23 -2
View File
@@ -89,7 +89,7 @@ async function getTagsForTickets(ticketIds: string[]): Promise<Map<string, TagDT
for (const row of rows) {
const list = map.get(row.ticketId) ?? [];
list.push({ id: row.tag.id, name: row.tag.name, color: row.tag.color });
list.push({ id: row.tag.id, name: row.tag.name, color: row.tag.color, isDefault: row.tag.isDefault });
map.set(row.ticketId, list);
}
return map;
@@ -158,6 +158,13 @@ async function appendMessage(params: {
}));
}
if (params.ticketIsNew) {
const defaultTagId = await getDefaultTagId();
if (defaultTagId) {
await db.insert(ticketTags).values({ ticketId: params.ticket.id, tagId: defaultTagId });
}
}
const ticket = await touchTicket(params.ticket.id, { lastMessageAt: new Date() });
const name = await customerName(ticket.customerId);
const ticketTagsList = await getTagsForTicket(ticket.id);
@@ -685,7 +692,7 @@ export async function deleteCannedResponse(id: string) {
export async function listTags(): Promise<TagDTO[]> {
const rows = await db.query.tags.findMany({ orderBy: tagsTable.name });
return rows.map((r) => ({ id: r.id, name: r.name, color: r.color }));
return rows.map((r) => ({ id: r.id, name: r.name, color: r.color, isDefault: r.isDefault }));
}
export async function createTag(name: string, color: TagDTO["color"]) {
@@ -697,6 +704,20 @@ export async function deleteTag(id: string) {
await db.delete(tagsTable).where(eq(tagsTable.id, id));
}
/** At most one default at a time — pass null to clear it entirely. */
export async function setDefaultTag(id: string | null): Promise<TagDTO[]> {
await db.update(tagsTable).set({ isDefault: false }).where(eq(tagsTable.isDefault, true));
if (id) {
await db.update(tagsTable).set({ isDefault: true }).where(eq(tagsTable.id, id));
}
return listTags();
}
async function getDefaultTagId(): Promise<string | null> {
const row = await db.query.tags.findFirst({ where: eq(tagsTable.isDefault, true) });
return row?.id ?? null;
}
/** Full replace — the ticket ends up tagged with exactly this set. */
export async function setTicketTags(ticketId: string, tagIds: string[]): Promise<TagDTO[]> {
await db.delete(ticketTags).where(eq(ticketTags.ticketId, ticketId));
+1
View File
@@ -10,6 +10,7 @@ export interface TagDTO {
id: string;
name: string;
color: TagColor;
isDefault: boolean;
}
export interface TicketDTO {