From fab1cbe4762b73080f3741485fd7cca2b432489d Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 5 Oct 2025 00:00:14 -0300 Subject: [PATCH] feat: add ticket category model and align ticket ui\n\nCo-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- web/convex/_generated/api.d.ts | 2 + web/convex/categories.ts | 510 ++++++++++++++++++ web/convex/schema.ts | 28 + web/convex/tickets.ts | 103 ++++ web/src/app/tickets/new/page.tsx | 42 +- .../components/tickets/category-select.tsx | 135 +++++ .../components/tickets/new-ticket-dialog.tsx | 46 +- .../components/tickets/priority-select.tsx | 24 +- web/src/components/tickets/status-select.tsx | 24 +- .../tickets/ticket-summary-header.tsx | 98 +++- .../components/tickets/ticket-timeline.tsx | 10 + web/src/components/tickets/tickets-table.tsx | 7 + web/src/components/ui/sonner.tsx | 45 +- web/src/hooks/use-ticket-categories.ts | 35 ++ web/src/lib/mappers/ticket.ts | 19 + web/src/lib/schemas/category.ts | 20 + web/src/lib/schemas/ticket.ts | 15 + 17 files changed, 1121 insertions(+), 42 deletions(-) create mode 100644 web/convex/categories.ts create mode 100644 web/src/components/tickets/category-select.tsx create mode 100644 web/src/hooks/use-ticket-categories.ts create mode 100644 web/src/lib/schemas/category.ts diff --git a/web/convex/_generated/api.d.ts b/web/convex/_generated/api.d.ts index d60a451..55d1ba2 100644 --- a/web/convex/_generated/api.d.ts +++ b/web/convex/_generated/api.d.ts @@ -9,6 +9,7 @@ */ import type * as bootstrap from "../bootstrap.js"; +import type * as categories from "../categories.js"; import type * as files from "../files.js"; import type * as queues from "../queues.js"; import type * as seed from "../seed.js"; @@ -31,6 +32,7 @@ import type { */ declare const fullApi: ApiFromModules<{ bootstrap: typeof bootstrap; + categories: typeof categories; files: typeof files; queues: typeof queues; seed: typeof seed; diff --git a/web/convex/categories.ts b/web/convex/categories.ts new file mode 100644 index 0000000..9c74b74 --- /dev/null +++ b/web/convex/categories.ts @@ -0,0 +1,510 @@ +import { mutation, query } from "./_generated/server" +import type { MutationCtx } from "./_generated/server" +import { ConvexError, v } from "convex/values" +import { Id } from "./_generated/dataModel" + +type CategorySeed = { + name: string + description?: string + secondary: string[] +} + +const DEFAULT_CATEGORY_SEED: CategorySeed[] = [ + { + name: "Backup", + secondary: ["Instalação, configuração ou agendamento", "Restauração"], + }, + { + name: "Certificado digital", + secondary: ["Instalação", "Reparo"], + }, + { + name: "E-mail", + secondary: [ + "Cota excedida", + "Criação, remoção ou configuração", + "Mensagem de caixa cheia", + "Migração", + "Não envia ou recebe", + "Política Office 365", + "Problemas com anexo", + "Problemas com spam", + "Problemas na base de dados", + "Relay de servidores ou aplicação", + "Resetar ou alterar senha", + ], + }, + { + name: "Estações", + secondary: [ + "Formatação ou clonagem", + "Instalação de SSD ou memória", + "Lentidão ou travamento", + "Problemas com o sistema operacional", + ], + }, + { + name: "Firewall / Roteador", + secondary: ["Configuração de VPN", "Instalação, restrição ou reparo", "Liberação ou restrição de sites"], + }, + { + name: "Hardware", + secondary: [ + "Bateria de lítio", + "Fonte de alimentação", + "HD", + "Limpeza de PC", + "Memória", + "Monitor", + "Nobreak", + "Placa de rede", + "Placa de vídeo", + "Placa-mãe", + "Troca de peça", + ], + }, + { + name: "Implantação", + secondary: ["Implantação Rever"], + }, + { + name: "Implantação de serviços", + secondary: ["Antivírus", "E-mail", "Firewall", "Office", "Sistemas"], + }, + { + name: "Impressora", + secondary: [ + "Configuração", + "Instalação de impressora", + "Instalação de scanner", + "Problemas de impressão", + "Problemas de scanner", + ], + }, + { + name: "Internet / Rede", + secondary: [ + "Lentidão", + "Mapear unidade de rede", + "Problemas de acesso ao Wi-Fi", + "Problemas de cabeamento", + "Problemas no switch", + "Sem acesso à internet", + "Sem acesso à rede", + "Sem acesso a um site específico", + ], + }, + { + name: "Kernel Panic Full", + secondary: ["Firewall", "Internet", "Provedor de e-mail", "Servidor", "Wi-Fi"], + }, + { + name: "Orçamento", + secondary: ["Computadores", "Periféricos", "Serviços", "Softwares", "Servidores"], + }, + { + name: "Procedimento de admissão/desligamento", + secondary: ["Admissão", "Desligamento"], + }, + { + name: "Projetos", + secondary: ["Projeto de infraestrutura", "Projeto de Wi-Fi", "Projeto de servidor"], + }, + { + name: "Relatório / Licenciamento", + secondary: [ + "Levantamento de NFs de softwares", + "Licenças", + "Preencher auditoria Microsoft", + "Relatório de estações", + ], + }, + { + name: "Servidor", + secondary: [ + "Adicionar ou trocar HD", + "Configuração de AD/Pastas/GPO", + "Configuração de SO", + "Configuração ou reparo de TS", + "Criação ou remoção de usuário", + "Lentidão ou travamento", + "Problemas de login", + ], + }, + { + name: "Sistema de produção (ERP)", + secondary: [ + "Instalação, atualização, configuração ou reparo", + "Lentidão ou travamento", + "Mensagem de erro", + "Phoenix atualização ou configuração", + "SCI ÚNICO atualização ou configuração", + "SCI ÚNICO lentidão", + "SCI VISUAL atualização ou configuração", + "SCI VISUAL lentidão ou travamento", + ], + }, + { + name: "Software APP", + secondary: [ + "Ativação do Office", + "Ativação do Windows", + "Instalação, atualização, configuração ou reparo", + ], + }, + { + name: "Telefonia", + secondary: ["Instalação, atualização, configuração ou reparo"], + }, + { + name: "Visita de rotina", + secondary: ["Serviços agendados"], + }, +] + +function slugify(value: string) { + return value + .normalize("NFD") + .replace(/[^\w\s-]/g, "") + .replace(/[\u0300-\u036f]/g, "") + .trim() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") +} + +async function ensureUniqueSlug( + ctx: Pick, + table: "ticketCategories" | "ticketSubcategories", + tenantId: string, + base: string, + scope: { categoryId?: Id<"ticketCategories"> } +) { + let slug = base || "categoria" + let counter = 1 + while (true) { + const existing = + table === "ticketCategories" + ? await ctx.db + .query("ticketCategories") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug)) + .first() + : await ctx.db + .query("ticketSubcategories") + .withIndex("by_category_slug", (q) => q.eq("categoryId", scope.categoryId!).eq("slug", slug)) + .first() + if (!existing) return slug + slug = `${base}-${counter}` + counter += 1 + } +} + +export const list = query({ + args: { tenantId: v.string() }, + handler: async (ctx, { tenantId }) => { + const categories = await ctx.db + .query("ticketCategories") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + .collect() + + if (categories.length === 0) { + return [] + } + + const subcategories = await ctx.db + .query("ticketSubcategories") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId)) + .collect() + + return categories.map((category) => ({ + id: category._id, + name: category.name, + slug: category.slug, + description: category.description, + order: category.order, + secondary: subcategories + .filter((item) => item.categoryId === category._id) + .sort((a, b) => a.order - b.order) + .map((item) => ({ + id: item._id, + name: item.name, + slug: item.slug, + order: item.order, + })), + })) + }, +}) + +export const ensureDefaults = mutation({ + args: { tenantId: v.string() }, + handler: async (ctx, { tenantId }) => { + const existingCount = await ctx.db + .query("ticketCategories") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + if (existingCount.length > 0) { + return { created: 0 } + } + + const now = Date.now() + let created = 0 + let order = 0 + + for (const seed of DEFAULT_CATEGORY_SEED) { + const baseSlug = slugify(seed.name) + const slug = await ensureUniqueSlug(ctx, "ticketCategories", tenantId, baseSlug, {}) + const categoryId = await ctx.db.insert("ticketCategories", { + tenantId, + name: seed.name, + slug, + description: seed.description, + order, + createdAt: now, + updatedAt: now, + }) + created += 1 + let subOrder = 0 + for (const secondary of seed.secondary) { + const subSlug = await ensureUniqueSlug( + ctx, + "ticketSubcategories", + tenantId, + slugify(secondary), + { categoryId } + ) + await ctx.db.insert("ticketSubcategories", { + tenantId, + categoryId, + name: secondary, + slug: subSlug, + order: subOrder, + createdAt: now, + updatedAt: now, + }) + subOrder += 1 + } + order += 1 + } + + return { created } + }, +}) + +export const createCategory = mutation({ + args: { + tenantId: v.string(), + name: v.string(), + description: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, name, description }) => { + const trimmed = name.trim() + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para a categoria") + } + const baseSlug = slugify(trimmed) + const slug = await ensureUniqueSlug(ctx, "ticketCategories", tenantId, baseSlug, {}) + const now = Date.now() + const last = await ctx.db + .query("ticketCategories") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + .order("desc") + .first() + const order = (last?.order ?? -1) + 1 + const id = await ctx.db.insert("ticketCategories", { + tenantId, + name: trimmed, + slug, + description, + order, + createdAt: now, + updatedAt: now, + }) + return id + }, +}) + +export const updateCategory = mutation({ + args: { + categoryId: v.id("ticketCategories"), + tenantId: v.string(), + name: v.string(), + description: v.optional(v.string()), + }, + handler: async (ctx, { categoryId, tenantId, name, description }) => { + const category = await ctx.db.get(categoryId) + if (!category || category.tenantId !== tenantId) { + throw new ConvexError("Categoria não encontrada") + } + const trimmed = name.trim() + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para a categoria") + } + const now = Date.now() + await ctx.db.patch(categoryId, { + name: trimmed, + description, + updatedAt: now, + }) + }, +}) + +export const deleteCategory = mutation({ + args: { + categoryId: v.id("ticketCategories"), + tenantId: v.string(), + transferTo: v.optional(v.id("ticketCategories")), + }, + handler: async (ctx, { categoryId, tenantId, transferTo }) => { + const category = await ctx.db.get(categoryId) + if (!category || category.tenantId !== tenantId) { + throw new ConvexError("Categoria não encontrada") + } + if (transferTo) { + const target = await ctx.db.get(transferTo) + if (!target || target.tenantId !== tenantId) { + throw new ConvexError("Categoria de destino inválida") + } + const subs = await ctx.db + .query("ticketSubcategories") + .withIndex("by_category_order", (q) => q.eq("categoryId", categoryId)) + .collect() + for (const sub of subs) { + await ctx.db.patch(sub._id, { + categoryId: transferTo, + updatedAt: Date.now(), + }) + } + const ticketsToMove = await ctx.db + .query("tickets") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .filter((q) => q.eq(q.field("categoryId"), categoryId)) + .collect() + for (const ticket of ticketsToMove) { + await ctx.db.patch(ticket._id, { + categoryId: transferTo, + subcategoryId: undefined, + updatedAt: Date.now(), + }) + } + } else { + const ticketsLinked = await ctx.db + .query("tickets") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .filter((q) => q.eq(q.field("categoryId"), categoryId)) + .first() + if (ticketsLinked) { + throw new ConvexError("Não é possível remover uma categoria vinculada a tickets sem informar destino") + } + const subs = await ctx.db + .query("ticketSubcategories") + .withIndex("by_category_order", (q) => q.eq("categoryId", categoryId)) + .collect() + for (const sub of subs) { + await ctx.db.delete(sub._id) + } + } + await ctx.db.delete(categoryId) + }, +}) + +export const createSubcategory = mutation({ + args: { + tenantId: v.string(), + categoryId: v.id("ticketCategories"), + name: v.string(), + }, + handler: async (ctx, { tenantId, categoryId, name }) => { + const category = await ctx.db.get(categoryId) + if (!category || category.tenantId !== tenantId) { + throw new ConvexError("Categoria não encontrada") + } + const trimmed = name.trim() + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para a subcategoria") + } + const baseSlug = slugify(trimmed) + const slug = await ensureUniqueSlug(ctx, "ticketSubcategories", tenantId, baseSlug, { categoryId }) + const now = Date.now() + const last = await ctx.db + .query("ticketSubcategories") + .withIndex("by_category_order", (q) => q.eq("categoryId", categoryId)) + .order("desc") + .first() + const order = (last?.order ?? -1) + 1 + const id = await ctx.db.insert("ticketSubcategories", { + tenantId, + categoryId, + name: trimmed, + slug, + order, + createdAt: now, + updatedAt: now, + }) + return id + }, +}) + +export const updateSubcategory = mutation({ + args: { + tenantId: v.string(), + subcategoryId: v.id("ticketSubcategories"), + name: v.string(), + }, + handler: async (ctx, { tenantId, subcategoryId, name }) => { + const subcategory = await ctx.db.get(subcategoryId) + if (!subcategory || subcategory.tenantId !== tenantId) { + throw new ConvexError("Subcategoria não encontrada") + } + const trimmed = name.trim() + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para a subcategoria") + } + await ctx.db.patch(subcategoryId, { + name: trimmed, + updatedAt: Date.now(), + }) + }, +}) + +export const deleteSubcategory = mutation({ + args: { + tenantId: v.string(), + subcategoryId: v.id("ticketSubcategories"), + transferTo: v.optional(v.id("ticketSubcategories")), + }, + handler: async (ctx, { tenantId, subcategoryId, transferTo }) => { + const subcategory = await ctx.db.get(subcategoryId) + if (!subcategory || subcategory.tenantId !== tenantId) { + throw new ConvexError("Subcategoria não encontrada") + } + if (transferTo) { + const target = await ctx.db.get(transferTo) + if (!target || target.tenantId !== tenantId) { + throw new ConvexError("Subcategoria destino inválida") + } + const tickets = await ctx.db + .query("tickets") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .filter((q) => q.eq(q.field("subcategoryId"), subcategoryId)) + .collect() + for (const ticket of tickets) { + await ctx.db.patch(ticket._id, { + subcategoryId: transferTo, + updatedAt: Date.now(), + }) + } + } else { + const linked = await ctx.db + .query("tickets") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .filter((q) => q.eq(q.field("subcategoryId"), subcategoryId)) + .first() + if (linked) { + throw new ConvexError("Não é possível remover uma subcategoria vinculada a tickets sem informar destino") + } + } + await ctx.db.delete(subcategoryId) + }, +}) diff --git a/web/convex/schema.ts b/web/convex/schema.ts index 61b56f0..ffdf8c7 100644 --- a/web/convex/schema.ts +++ b/web/convex/schema.ts @@ -46,6 +46,8 @@ export default defineSchema({ priority: v.string(), channel: v.string(), queueId: v.optional(v.id("queues")), + categoryId: v.optional(v.id("ticketCategories")), + subcategoryId: v.optional(v.id("ticketSubcategories")), requesterId: v.id("users"), assigneeId: v.optional(v.id("users")), working: v.optional(v.boolean()), @@ -101,4 +103,30 @@ export default defineSchema({ }) .index("by_ticket", ["ticketId"]) .index("by_ticket_agent", ["ticketId", "agentId"]), + + ticketCategories: defineTable({ + tenantId: v.string(), + name: v.string(), + slug: v.string(), + description: v.optional(v.string()), + order: v.number(), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_tenant_slug", ["tenantId", "slug"]) + .index("by_tenant_order", ["tenantId", "order"]) + .index("by_tenant", ["tenantId"]), + + ticketSubcategories: defineTable({ + tenantId: v.string(), + categoryId: v.id("ticketCategories"), + name: v.string(), + slug: v.string(), + order: v.number(), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_category_order", ["categoryId", "order"]) + .index("by_category_slug", ["categoryId", "slug"]) + .index("by_tenant_slug", ["tenantId", "slug"]), }); diff --git a/web/convex/tickets.ts b/web/convex/tickets.ts index cb3d867..e609ff2 100644 --- a/web/convex/tickets.ts +++ b/web/convex/tickets.ts @@ -47,6 +47,8 @@ export const list = query({ ); } const limited = args.limit ? filtered.slice(0, args.limit) : filtered; + const categoryCache = new Map | null>(); + const subcategoryCache = new Map | null>(); // hydrate requester and assignee const result = await Promise.all( limited.map(async (t) => { @@ -54,6 +56,26 @@ export const list = query({ const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null; const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null; const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null; + let categorySummary: { id: Id<"ticketCategories">; name: string } | null = null; + let subcategorySummary: { id: Id<"ticketSubcategories">; name: string } | null = null; + if (t.categoryId) { + if (!categoryCache.has(t.categoryId)) { + categoryCache.set(t.categoryId, await ctx.db.get(t.categoryId)); + } + const category = categoryCache.get(t.categoryId); + if (category) { + categorySummary = { id: category._id, name: category.name }; + } + } + if (t.subcategoryId) { + if (!subcategoryCache.has(t.subcategoryId)) { + subcategoryCache.set(t.subcategoryId, await ctx.db.get(t.subcategoryId)); + } + const subcategory = subcategoryCache.get(t.subcategoryId); + if (subcategory) { + subcategorySummary = { id: subcategory._id, name: subcategory.name }; + } + } return { id: t._id, reference: t.reference, @@ -89,6 +111,8 @@ export const list = query({ tags: t.tags ?? [], lastTimelineEntry: null, metrics: null, + category: categorySummary, + subcategory: subcategorySummary, workSummary: { totalWorkedMs: t.totalWorkedMs ?? 0, activeSession: activeSession @@ -115,6 +139,8 @@ export const getById = query({ const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null; const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null; const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null; + const category = t.categoryId ? await ctx.db.get(t.categoryId) : null; + const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null; const comments = await ctx.db .query("ticketComments") .withIndex("by_ticket", (q) => q.eq("ticketId", id)) @@ -191,6 +217,19 @@ export const getById = query({ tags: t.tags ?? [], lastTimelineEntry: null, metrics: null, + category: category + ? { + id: category._id, + name: category.name, + } + : null, + subcategory: subcategory + ? { + id: subcategory._id, + name: subcategory.name, + categoryId: subcategory.categoryId, + } + : null, workSummary: { totalWorkedMs: t.totalWorkedMs ?? 0, activeSession: activeSession @@ -223,12 +262,22 @@ export const create = mutation({ channel: v.string(), queueId: v.optional(v.id("queues")), requesterId: v.id("users"), + categoryId: v.id("ticketCategories"), + subcategoryId: v.id("ticketSubcategories"), }, handler: async (ctx, args) => { const subject = args.subject.trim(); if (subject.length < 3) { throw new ConvexError("Informe um assunto com pelo menos 3 caracteres"); } + const category = await ctx.db.get(args.categoryId); + if (!category || category.tenantId !== args.tenantId) { + throw new ConvexError("Categoria inválida"); + } + const subcategory = await ctx.db.get(args.subcategoryId); + if (!subcategory || subcategory.categoryId !== args.categoryId || subcategory.tenantId !== args.tenantId) { + throw new ConvexError("Subcategoria inválida"); + } // compute next reference (simple monotonic counter per tenant) const existing = await ctx.db .query("tickets") @@ -246,6 +295,8 @@ export const create = mutation({ priority: args.priority, channel: args.channel, queueId: args.queueId, + categoryId: args.categoryId, + subcategoryId: args.subcategoryId, requesterId: args.requesterId, assigneeId: undefined, working: false, @@ -409,6 +460,58 @@ export const changeQueue = mutation({ }, }); +export const updateCategories = mutation({ + args: { + ticketId: v.id("tickets"), + categoryId: v.id("ticketCategories"), + subcategoryId: v.id("ticketSubcategories"), + actorId: v.id("users"), + }, + handler: async (ctx, { ticketId, categoryId, subcategoryId, actorId }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const category = await ctx.db.get(categoryId) + if (!category || category.tenantId !== ticket.tenantId) { + throw new ConvexError("Categoria inválida") + } + const subcategory = await ctx.db.get(subcategoryId) + if (!subcategory || subcategory.categoryId !== categoryId || subcategory.tenantId !== ticket.tenantId) { + throw new ConvexError("Subcategoria inválida") + } + + if (ticket.categoryId === categoryId && ticket.subcategoryId === subcategoryId) { + return { status: "unchanged" } + } + + const now = Date.now() + await ctx.db.patch(ticketId, { + categoryId, + subcategoryId, + updatedAt: now, + }) + + const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null + await ctx.db.insert("ticketEvents", { + ticketId, + type: "CATEGORY_CHANGED", + payload: { + categoryId, + categoryName: category.name, + subcategoryId, + subcategoryName: subcategory.name, + actorId, + actorName: actor?.name, + actorAvatar: actor?.avatarUrl, + }, + createdAt: now, + }) + + return { status: "updated" } + }, +}) + export const workSummary = query({ args: { ticketId: v.id("tickets") }, handler: async (ctx, { ticketId }) => { diff --git a/web/src/app/tickets/new/page.tsx b/web/src/app/tickets/new/page.tsx index e110da6..667bc27 100644 --- a/web/src/app/tickets/new/page.tsx +++ b/web/src/app/tickets/new/page.tsx @@ -26,14 +26,15 @@ import { priorityStyles, priorityTriggerClass, } from "@/components/tickets/priority-select" +import { CategorySelectFields } from "@/components/tickets/category-select" export default function NewTicketPage() { const router = useRouter() const { userId } = useAuth() - const queues = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? [] + const queuesRaw = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined + const queues = useMemo(() => queuesRaw ?? [], [queuesRaw]) const create = useMutation(api.tickets.create) const addComment = useMutation(api.tickets.addComment) - const ensureDefaults = useMutation(api.bootstrap.ensureDefaults) const [subject, setSubject] = useState("") const [summary, setSummary] = useState("") @@ -43,6 +44,10 @@ export default function NewTicketPage() { const [description, setDescription] = useState("") const [loading, setLoading] = useState(false) const [subjectError, setSubjectError] = useState(null) + const [categoryId, setCategoryId] = useState(null) + const [subcategoryId, setSubcategoryId] = useState(null) + const [categoryError, setCategoryError] = useState(null) + const [subcategoryError, setSubcategoryError] = useState(null) const queueOptions = useMemo(() => queues.map((q) => q.name), [queues]) @@ -57,10 +62,18 @@ export default function NewTicketPage() { } setSubjectError(null) + if (!categoryId) { + setCategoryError("Selecione uma categoria.") + return + } + if (!subcategoryId) { + setSubcategoryError("Selecione uma categoria secundária.") + return + } + setLoading(true) toast.loading("Criando ticket...", { id: "create-ticket" }) try { - if (queues.length === 0) await ensureDefaults({ tenantId: DEFAULT_TENANT_ID }) const selQueue = queues.find((q) => q.name === queueName) const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined const id = await create({ @@ -71,6 +84,8 @@ export default function NewTicketPage() { channel, queueId, requesterId: userId as Id<"users">, + categoryId: categoryId as Id<"ticketCategories">, + subcategoryId: subcategoryId as Id<"ticketSubcategories">, }) const plainDescription = description.replace(/<[^>]*>/g, "").trim() if (plainDescription.length > 0) { @@ -135,6 +150,27 @@ export default function NewTicketPage() { +
+ { + setCategoryId(value) + setCategoryError(null) + }} + onSubcategoryChange={(value) => { + setSubcategoryId(value) + setSubcategoryError(null) + }} + /> + {categoryError || subcategoryError ? ( +
+ {categoryError ?
{categoryError}
: null} + {subcategoryError ?
{subcategoryError}
: null} +
+ ) : null} +
Prioridade diff --git a/web/src/components/tickets/category-select.tsx b/web/src/components/tickets/category-select.tsx new file mode 100644 index 0000000..522fdcf --- /dev/null +++ b/web/src/components/tickets/category-select.tsx @@ -0,0 +1,135 @@ +"use client" + +import { useEffect, useMemo } from "react" +import { IconFolders, IconFolder } from "@tabler/icons-react" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { cn } from "@/lib/utils" +import { useTicketCategories } from "@/hooks/use-ticket-categories" +import type { TicketCategory } from "@/lib/schemas/category" + +const triggerClass = + "flex h-9 w-full items-center justify-between rounded-lg border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 shadow-sm transition focus:ring-0 data-[state=open]:border-[#00d6eb]" +const contentClass = "rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md" +const itemClass = + "flex items-center justify-between gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10" + +interface CategorySelectProps { + tenantId: string + categoryId: string | null + subcategoryId: string | null + onCategoryChange: (categoryId: string) => void + onSubcategoryChange: (subcategoryId: string) => void + autoSelectFirst?: boolean + disabled?: boolean + categoryLabel?: string + subcategoryLabel?: string + className?: string + secondaryEmptyLabel?: string +} + +function findCategory(categories: TicketCategory[], categoryId: string | null) { + if (!categoryId) return null + return categories.find((category) => category.id === categoryId) ?? null +} + +export function CategorySelectFields({ + tenantId, + categoryId, + subcategoryId, + onCategoryChange, + onSubcategoryChange, + autoSelectFirst = true, + disabled = false, + categoryLabel = "Primária", + subcategoryLabel = "Secundária", + secondaryEmptyLabel = "Selecione uma categoria primária", + className, +}: CategorySelectProps) { + const { categories, isLoading } = useTicketCategories(tenantId) + const activeCategory = useMemo(() => findCategory(categories, categoryId), [categories, categoryId]) + const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory]) + + useEffect(() => { + if (!autoSelectFirst || isLoading) return + if (categories.length === 0) return + if (categoryId) return + const first = categories[0] + if (first) { + onCategoryChange(first.id) + const firstSecondary = first.secondary[0] + if (firstSecondary) { + onSubcategoryChange(firstSecondary.id) + } + } + }, [autoSelectFirst, categories, categoryId, isLoading, onCategoryChange, onSubcategoryChange]) + + useEffect(() => { + if (!categoryId || secondaryOptions.length === 0) return + const stillValid = secondaryOptions.some((item) => item.id === subcategoryId) + if (!stillValid) { + const first = secondaryOptions[0] + if (first) { + onSubcategoryChange(first.id) + } + } + }, [categoryId, secondaryOptions, subcategoryId, onSubcategoryChange]) + + return ( +
+
+ + +
+
+ + +
+
+ ) +} diff --git a/web/src/components/tickets/new-ticket-dialog.tsx b/web/src/components/tickets/new-ticket-dialog.tsx index 87d0667..928e6bb 100644 --- a/web/src/components/tickets/new-ticket-dialog.tsx +++ b/web/src/components/tickets/new-ticket-dialog.tsx @@ -1,7 +1,7 @@ "use client" import { z } from "zod" -import { useState } from "react" +import { useMemo, useState } from "react" import type { Id } from "@/convex/_generated/dataModel" import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket" import { useMutation, useQuery } from "convex/react" @@ -29,6 +29,7 @@ import { priorityStyles, priorityTriggerClass, } from "@/components/tickets/priority-select" +import { CategorySelectFields } from "@/components/tickets/category-select" const schema = z.object({ subject: z.string().min(3, "Informe um assunto"), @@ -37,6 +38,8 @@ const schema = z.object({ priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"), channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"), queueName: z.string().nullable().optional(), + categoryId: z.string().min(1, "Selecione uma categoria"), + subcategoryId: z.string().min(1, "Selecione uma categoria secundária"), }) export function NewTicketDialog() { @@ -44,17 +47,29 @@ export function NewTicketDialog() { const [loading, setLoading] = useState(false) const form = useForm>({ resolver: zodResolver(schema), - defaultValues: { subject: "", summary: "", description: "", priority: "MEDIUM", channel: "MANUAL", queueName: null }, + defaultValues: { + subject: "", + summary: "", + description: "", + priority: "MEDIUM", + channel: "MANUAL", + queueName: null, + categoryId: "", + subcategoryId: "", + }, mode: "onTouched", }) const { userId } = useAuth() - const queues = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? [] + const queuesRaw = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined + const queues = useMemo(() => queuesRaw ?? [], [queuesRaw]) const create = useMutation(api.tickets.create) const addComment = useMutation(api.tickets.addComment) const [attachments, setAttachments] = useState>([]) const priorityValue = form.watch("priority") as TicketPriority const channelValue = form.watch("channel") const queueValue = form.watch("queueName") ?? "NONE" + const categoryIdValue = form.watch("categoryId") + const subcategoryIdValue = form.watch("subcategoryId") const selectTriggerClass = "flex h-8 w-full items-center justify-between rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" const selectItemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10" @@ -77,6 +92,8 @@ export function NewTicketDialog() { channel: values.channel, queueId: sel?.id as Id<"queues"> | undefined, requesterId: userId as Id<"users">, + categoryId: values.categoryId as Id<"ticketCategories">, + subcategoryId: values.subcategoryId as Id<"ticketSubcategories">, }) const hasDescription = (values.description ?? "").replace(/<[^>]*>/g, "").trim().length > 0 const bodyHtml = hasDescription ? (values.description as string) : (values.summary || "") @@ -95,7 +112,7 @@ export function NewTicketDialog() { setAttachments([]) // Navegar para o ticket recém-criado window.location.href = `/tickets/${id}` - } catch (err) { + } catch { toast.error("Não foi possível criar o ticket.", { id: "new-ticket" }) } finally { setLoading(false) @@ -142,6 +159,27 @@ export function NewTicketDialog() { setAttachments((prev) => [...prev, ...files])} /> Formatos comuns de imagem e documentos são aceitos. + + { + form.setValue("categoryId", value, { shouldDirty: true, shouldValidate: true }) + }} + onSubcategoryChange={(value) => { + form.setValue("subcategoryId", value, { shouldDirty: true, shouldValidate: true }) + }} + /> + {form.formState.errors.categoryId?.message || form.formState.errors.subcategoryId?.message ? ( + + <> + {form.formState.errors.categoryId?.message ?
{form.formState.errors.categoryId?.message}
: null} + {form.formState.errors.subcategoryId?.message ?
{form.formState.errors.subcategoryId?.message}
: null} + +
+ ) : null} +
Prioridade diff --git a/web/src/components/tickets/priority-select.tsx b/web/src/components/tickets/priority-select.tsx index 46dada8..1f11141 100644 --- a/web/src/components/tickets/priority-select.tsx +++ b/web/src/components/tickets/priority-select.tsx @@ -10,20 +10,25 @@ import { useAuth } from "@/lib/auth-client" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Badge } from "@/components/ui/badge" import { toast } from "sonner" -import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react" +import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp, ChevronDown } from "lucide-react" import { cn } from "@/lib/utils" export const priorityStyles: Record = { - LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" }, - MEDIUM: { label: "Média", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" }, - HIGH: { label: "Alta", badgeClass: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" }, - URGENT: { label: "Urgente", badgeClass: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" }, + LOW: { label: "Baixa", badgeClass: "bg-slate-100 text-slate-700" }, + MEDIUM: { label: "Média", badgeClass: "bg-[#dff1fb] text-[#0a4760]" }, + HIGH: { label: "Alta", badgeClass: "bg-[#fde8d1] text-[#7d3b05]" }, + URGENT: { label: "Urgente", badgeClass: "bg-[#fbd9dd] text-[#8b0f1c]" }, } -export const priorityTriggerClass = "h-8 w-[160px] rounded-full border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" +export const priorityTriggerClass = + "h-8 w-[160px] rounded-full border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" export const priorityItemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10" const iconClass = "size-4 text-neutral-700" -export const priorityBadgeClass = "inline-flex items-center gap-2 rounded-full px-3 py-0.5 text-xs font-semibold" +export const priorityBadgeClass = + "inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-semibold transition hover:border-slate-300" + +const headerTriggerClass = + "group inline-flex h-auto w-auto items-center justify-center rounded-full border border-transparent bg-transparent p-0 shadow-none ring-0 ring-offset-0 ring-offset-transparent focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none hover:bg-transparent data-[state=open]:bg-transparent data-[state=open]:border-transparent data-[state=open]:shadow-none data-[state=open]:ring-0 data-[state=open]:ring-offset-0 data-[state=open]:ring-offset-transparent [&>*:last-child]:hidden" export function PriorityIcon({ value }: { value: TicketPriority }) { if (value === "LOW") return @@ -55,11 +60,12 @@ export function PrioritySelect({ ticketId, value }: { ticketId: string; value: T } }} > - - + + {priorityStyles[priority]?.label ?? priority} + diff --git a/web/src/components/tickets/status-select.tsx b/web/src/components/tickets/status-select.tsx index 518ff24..e53c299 100644 --- a/web/src/components/tickets/status-select.tsx +++ b/web/src/components/tickets/status-select.tsx @@ -11,19 +11,22 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Badge } from "@/components/ui/badge" import { toast } from "sonner" import { cn } from "@/lib/utils" +import { ChevronDown } from "lucide-react" const statusStyles: Record = { - NEW: { label: "Novo", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" }, - OPEN: { label: "Aberto", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" }, - PENDING: { label: "Pendente", badgeClass: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" }, - ON_HOLD: { label: "Em espera", badgeClass: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" }, - RESOLVED: { label: "Resolvido", badgeClass: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" }, - CLOSED: { label: "Fechado", badgeClass: "border border-slate-200 bg-slate-200 text-slate-700" }, + NEW: { label: "Novo", badgeClass: "bg-slate-100 text-slate-700" }, + OPEN: { label: "Aberto", badgeClass: "bg-[#dff1fb] text-[#0a4760]" }, + PENDING: { label: "Pendente", badgeClass: "bg-[#fdebd6] text-[#7b4107]" }, + ON_HOLD: { label: "Em espera", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" }, + RESOLVED: { label: "Resolvido", badgeClass: "bg-[#dcf4eb] text-[#1f6a45]" }, + CLOSED: { label: "Fechado", badgeClass: "bg-slate-200 text-slate-700" }, } -const triggerClass = "h-8 w-[180px] rounded-full border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" +const triggerClass = + "group inline-flex h-auto w-auto items-center justify-center rounded-full border border-transparent bg-transparent p-0 shadow-none ring-0 ring-offset-0 ring-offset-transparent focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none hover:bg-transparent data-[state=open]:bg-transparent data-[state=open]:border-transparent data-[state=open]:shadow-none data-[state=open]:ring-0 data-[state=open]:ring-offset-0 data-[state=open]:ring-offset-transparent [&>*:last-child]:hidden" const itemClass = "rounded-md px-2 py-2 text-sm text-neutral-800 transition hover:bg-slate-100 data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10" -const baseBadgeClass = "inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold" +const baseBadgeClass = + "inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-semibold transition hover:border-slate-300" export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) { const updateStatus = useMutation(api.tickets.updateStatus) @@ -48,10 +51,11 @@ export function StatusSelect({ ticketId, value }: { ticketId: string; value: Tic } }} > - - + + {statusStyles[status]?.label ?? status} + diff --git a/web/src/components/tickets/ticket-summary-header.tsx b/web/src/components/tickets/ticket-summary-header.tsx index 7dd3003..199eb4f 100644 --- a/web/src/components/tickets/ticket-summary-header.tsx +++ b/web/src/components/tickets/ticket-summary-header.tsx @@ -21,13 +21,14 @@ import { StatusSelect } from "@/components/tickets/status-select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { CategorySelectFields } from "@/components/tickets/category-select" interface TicketHeaderProps { ticket: TicketWithDetails } const cardClass = "relative space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm" -const referenceBadgeClass = "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-700" +const referenceBadgeClass = "inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700" const startButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30" const pauseButtonClass = @@ -64,6 +65,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { const updateSummary = useMutation(api.tickets.updateSummary) const startWork = useMutation(api.tickets.startWork) const pauseWork = useMutation(api.tickets.pauseWork) + const updateCategories = useMutation(api.tickets.updateCategories) const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? [] const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? [] const [status] = useState(ticket.status) @@ -79,6 +81,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { const [editing, setEditing] = useState(false) const [subject, setSubject] = useState(ticket.subject) const [summary, setSummary] = useState(ticket.summary ?? "") + const [categorySelection, setCategorySelection] = useState({ + categoryId: ticket.category?.id ?? "", + subcategoryId: ticket.subcategory?.id ?? "", + }) + const [savingCategory, setSavingCategory] = useState(false) const dirty = useMemo( () => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""), [subject, summary, ticket.subject, ticket.summary] @@ -107,6 +114,51 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { setEditing(false) } + useEffect(() => { + setCategorySelection({ + categoryId: ticket.category?.id ?? "", + subcategoryId: ticket.subcategory?.id ?? "", + }) + }, [ticket.category?.id, ticket.subcategory?.id]) + + const categoryDirty = useMemo(() => { + const currentCategory = ticket.category?.id ?? "" + const currentSubcategory = ticket.subcategory?.id ?? "" + return ( + categorySelection.categoryId !== currentCategory || categorySelection.subcategoryId !== currentSubcategory + ) + }, [categorySelection.categoryId, categorySelection.subcategoryId, ticket.category?.id, ticket.subcategory?.id]) + + const handleResetCategory = () => { + setCategorySelection({ + categoryId: ticket.category?.id ?? "", + subcategoryId: ticket.subcategory?.id ?? "", + }) + } + + async function handleSaveCategory() { + if (!userId) return + if (!categorySelection.categoryId || !categorySelection.subcategoryId) { + toast.error("Selecione uma categoria válida.") + return + } + setSavingCategory(true) + toast.loading("Atualizando categoria...", { id: "ticket-category" }) + try { + await updateCategories({ + ticketId: ticket.id as Id<"tickets">, + categoryId: categorySelection.categoryId as Id<"ticketCategories">, + subcategoryId: categorySelection.subcategoryId as Id<"ticketSubcategories">, + actorId: userId as Id<"users">, + }) + toast.success("Categoria atualizada!", { id: "ticket-category" }) + } catch { + toast.error("Não foi possível atualizar a categoria.", { id: "ticket-category" }) + } finally { + setSavingCategory(false) + } + } + const workSummary = useMemo(() => { if (workSummaryRemote !== undefined) return workSummaryRemote ?? null if (!ticket.workSummary) return null @@ -146,7 +198,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { return (
-
+
+ {workSummary ? ( + + Tempo total: {formattedTotalWorked} + + ) : null} {!editing ? (
{workSummary ? (
- - Tempo total: {formattedTotalWorked} - {isPlaying ? ( Sessão atual: {formattedCurrentSession} @@ -246,6 +300,40 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
+
+ Categorias + { + setCategorySelection((prev) => ({ ...prev, categoryId: value })) + }} + onSubcategoryChange={(value) => { + setCategorySelection((prev) => ({ ...prev, subcategoryId: value })) + }} + /> +
+ + +
+
Solicitante {ticket.requester.name} diff --git a/web/src/components/tickets/ticket-timeline.tsx b/web/src/components/tickets/ticket-timeline.tsx index d334756..c17e6a5 100644 --- a/web/src/components/tickets/ticket-timeline.tsx +++ b/web/src/components/tickets/ticket-timeline.tsx @@ -3,6 +3,7 @@ import type { ComponentType } from "react" import { ptBR } from "date-fns/locale" import { IconClockHour4, + IconFolders, IconNote, IconPaperclip, IconSquareCheck, @@ -26,6 +27,7 @@ const timelineIcons: Record> = { QUEUE_CHANGED: IconSquareCheck, PRIORITY_CHANGED: IconSquareCheck, ATTACHMENT_REMOVED: IconPaperclip, + CATEGORY_CHANGED: IconFolders, } const timelineLabels: Record = { @@ -40,6 +42,7 @@ const timelineLabels: Record = { QUEUE_CHANGED: "Fila alterada", PRIORITY_CHANGED: "Prioridade alterada", ATTACHMENT_REMOVED: "Anexo removido", + CATEGORY_CHANGED: "Categoria alterada", } interface TicketTimelineProps { @@ -110,6 +113,8 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { from?: string attachmentName?: string sessionDurationMs?: number + categoryName?: string + subcategoryName?: string } let message: string | null = null @@ -143,6 +148,11 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { if (entry.type === "WORK_PAUSED" && typeof payload.sessionDurationMs === "number") { message = `Tempo registrado: ${formatDuration(payload.sessionDurationMs)}` } + if (entry.type === "CATEGORY_CHANGED" && (payload.categoryName || payload.subcategoryName)) { + message = `Categoria alterada para ${payload.categoryName ?? ""}${ + payload.subcategoryName ? ` • ${payload.subcategoryName}` : "" + }` + } if (!message) return null return ( diff --git a/web/src/components/tickets/tickets-table.tsx b/web/src/components/tickets/tickets-table.tsx index c8d24eb..51251d2 100644 --- a/web/src/components/tickets/tickets-table.tsx +++ b/web/src/components/tickets/tickets-table.tsx @@ -36,6 +36,7 @@ const cellClass = "px-6 py-5 align-top text-sm text-neutral-700 first:pl-8 last: const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700" const channelBadgeClass = "inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700" const tagBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-[11px] font-medium text-neutral-600" +const categoryBadgeClass = "inline-flex items-center gap-1 rounded-full border border-[#00e8ff]/50 bg-[#00e8ff]/10 px-2.5 py-0.5 text-[11px] font-semibold text-[#02414d]" const tableRowClass = "group border-b border-slate-100 text-sm transition-colors hover:bg-slate-100/70 last:border-none" function formatDuration(ms?: number) { @@ -169,6 +170,12 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
{ticket.requester.name} + {ticket.category ? ( + + {ticket.category.name} + {ticket.subcategory ? ` • ${ticket.subcategory.name}` : ""} + + ) : null} {ticket.tags?.map((tag) => ( {tag} diff --git a/web/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx index 76a0bf6..d4bdd8b 100644 --- a/web/src/components/ui/sonner.tsx +++ b/web/src/components/ui/sonner.tsx @@ -11,18 +11,41 @@ const Toaster = ({ ...props }: ToasterProps) => { theme={theme as ToasterProps["theme"]} className="toaster group" toastOptions={{ + className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg", + style: { + background: "#000", + color: "#fff", + border: "1px solid #000", + }, classNames: { - toast: "border border-black bg-black text-white shadow-lg rounded-xl px-4 py-3 text-sm font-semibold", - success: "border border-black bg-black text-white", - error: "border border-black bg-black text-white", - info: "border border-black bg-black text-white", - warning: "border border-black bg-black text-white", - loading: "border border-black bg-black text-white", - title: "font-medium", - description: "text-white/80", - icon: "text-[#00e8ff]", - actionButton: "bg-white text-black border border-black rounded-lg", - cancelButton: "bg-transparent text-white border border-white/40 rounded-lg", + icon: "size-[14px] text-white [&>svg]:size-[14px] [&>svg]:text-white", + }, + descriptionClassName: "text-white/80", + actionButtonClassName: "bg-white text-black border border-black rounded-lg", + cancelButtonClassName: "bg-transparent text-white border border-white/40 rounded-lg", + iconTheme: { + primary: "#ffffff", + secondary: "#000000", + }, + success: { + className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg", + style: { background: "#000", color: "#fff", border: "1px solid #000" }, + }, + error: { + className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg", + style: { background: "#000", color: "#fff", border: "1px solid #000" }, + }, + info: { + className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg", + style: { background: "#000", color: "#fff", border: "1px solid #000" }, + }, + warning: { + className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg", + style: { background: "#000", color: "#fff", border: "1px solid #000" }, + }, + loading: { + className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg", + style: { background: "#000", color: "#fff", border: "1px solid #000" }, }, }} style={ diff --git a/web/src/hooks/use-ticket-categories.ts b/web/src/hooks/use-ticket-categories.ts new file mode 100644 index 0000000..ad60571 --- /dev/null +++ b/web/src/hooks/use-ticket-categories.ts @@ -0,0 +1,35 @@ +"use client" + +import { useEffect, useMemo, useRef } from "react" +import { useMutation, useQuery } from "convex/react" +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { api } from "@/convex/_generated/api" +import type { TicketCategory } from "@/lib/schemas/category" + +export function useTicketCategories(tenantId: string) { + const categories = useQuery(api.categories.list, { tenantId }) as TicketCategory[] | undefined + const ensureDefaults = useMutation(api.categories.ensureDefaults) + const initializingRef = useRef(false) + + useEffect(() => { + if (!categories) return + if (categories.length > 0) { + initializingRef.current = false + return + } + if (initializingRef.current) return + initializingRef.current = true + ensureDefaults({ tenantId }).catch(() => { + initializingRef.current = false + }) + }, [categories, ensureDefaults, tenantId]) + + return useMemo( + () => ({ + categories: categories ?? [], + isLoading: categories === undefined, + }), + [categories] + ) +} diff --git a/web/src/lib/mappers/ticket.ts b/web/src/lib/mappers/ticket.ts index a744ab7..93a94e5 100644 --- a/web/src/lib/mappers/ticket.ts +++ b/web/src/lib/mappers/ticket.ts @@ -32,6 +32,21 @@ const serverTicketSchema = z.object({ tags: z.array(z.string()).default([]).optional(), lastTimelineEntry: z.string().nullable().optional(), metrics: z.any().nullable().optional(), + category: z + .object({ + id: z.string(), + name: z.string(), + }) + .nullable() + .optional(), + subcategory: z + .object({ + id: z.string(), + name: z.string(), + categoryId: z.string().optional(), + }) + .nullable() + .optional(), workSummary: z .object({ totalWorkedMs: z.number(), @@ -82,6 +97,8 @@ export function mapTicketFromServer(input: unknown) { const s = serverTicketSchema.parse(input); const ui = { ...s, + category: s.category ?? undefined, + subcategory: s.subcategory ?? undefined, lastTimelineEntry: s.lastTimelineEntry ?? undefined, updatedAt: new Date(s.updatedAt), createdAt: new Date(s.createdAt), @@ -112,6 +129,8 @@ export function mapTicketWithDetailsFromServer(input: unknown) { const ui = { ...s, customFields: (s.customFields ?? {}) as Record, + category: s.category ?? undefined, + subcategory: s.subcategory ?? undefined, lastTimelineEntry: s.lastTimelineEntry ?? undefined, updatedAt: new Date(s.updatedAt), createdAt: new Date(s.createdAt), diff --git a/web/src/lib/schemas/category.ts b/web/src/lib/schemas/category.ts new file mode 100644 index 0000000..b56a1a1 --- /dev/null +++ b/web/src/lib/schemas/category.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +export const ticketSubcategorySchema = z.object({ + id: z.string(), + name: z.string(), + slug: z.string().optional(), + order: z.number().optional(), + categoryId: z.string().optional(), +}) +export type TicketSubcategory = z.infer + +export const ticketCategorySchema = z.object({ + id: z.string(), + name: z.string(), + slug: z.string().optional(), + description: z.string().optional().nullable(), + order: z.number().optional(), + secondary: z.array(ticketSubcategorySchema), +}) +export type TicketCategory = z.infer diff --git a/web/src/lib/schemas/ticket.ts b/web/src/lib/schemas/ticket.ts index 0f85a55..8c2e267 100644 --- a/web/src/lib/schemas/ticket.ts +++ b/web/src/lib/schemas/ticket.ts @@ -35,6 +35,19 @@ export const userSummarySchema = z.object({ teams: z.array(z.string()).default([]), }) export type UserSummary = z.infer + +export const ticketCategorySummarySchema = z.object({ + id: z.string(), + name: z.string(), +}) +export type TicketCategorySummary = z.infer + +export const ticketSubcategorySummarySchema = z.object({ + id: z.string(), + name: z.string(), + categoryId: z.string().optional(), +}) +export type TicketSubcategorySummary = z.infer export const ticketCommentSchema = z.object({ id: z.string(), @@ -98,6 +111,8 @@ export const ticketSchema = z.object({ timeOpenedMinutes: z.number().nullable(), }) .nullable(), + category: ticketCategorySummarySchema.nullable().optional(), + subcategory: ticketSubcategorySummarySchema.nullable().optional(), workSummary: z .object({ totalWorkedMs: z.number(),