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:
1 parent
5af360fd15
commit
b11ec711c5
10 files changed
+1139
-15
No files matched your search
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -57,6 +57,13 @@
|
||||
"when": 1787124609684,
|
||||
"tag": "0007_neat_archangel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1787126651618,
|
||||
"tag": "0008_quick_ultragirl",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface TagDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
color: TagColor;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface TicketDTO {
|
||||
|
||||
Reference in new issue
Block a user