feat: add ticket category model and align ticket ui\n\nCo-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

This commit is contained in:
esdrasrenan 2025-10-05 00:00:14 -03:00
parent 55511f3a0e
commit fab1cbe476
17 changed files with 1121 additions and 42 deletions

View file

@ -9,6 +9,7 @@
*/ */
import type * as bootstrap from "../bootstrap.js"; import type * as bootstrap from "../bootstrap.js";
import type * as categories from "../categories.js";
import type * as files from "../files.js"; import type * as files from "../files.js";
import type * as queues from "../queues.js"; import type * as queues from "../queues.js";
import type * as seed from "../seed.js"; import type * as seed from "../seed.js";
@ -31,6 +32,7 @@ import type {
*/ */
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
bootstrap: typeof bootstrap; bootstrap: typeof bootstrap;
categories: typeof categories;
files: typeof files; files: typeof files;
queues: typeof queues; queues: typeof queues;
seed: typeof seed; seed: typeof seed;

510
web/convex/categories.ts Normal file
View file

@ -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<MutationCtx, "db">,
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)
},
})

View file

@ -46,6 +46,8 @@ export default defineSchema({
priority: v.string(), priority: v.string(),
channel: v.string(), channel: v.string(),
queueId: v.optional(v.id("queues")), queueId: v.optional(v.id("queues")),
categoryId: v.optional(v.id("ticketCategories")),
subcategoryId: v.optional(v.id("ticketSubcategories")),
requesterId: v.id("users"), requesterId: v.id("users"),
assigneeId: v.optional(v.id("users")), assigneeId: v.optional(v.id("users")),
working: v.optional(v.boolean()), working: v.optional(v.boolean()),
@ -101,4 +103,30 @@ export default defineSchema({
}) })
.index("by_ticket", ["ticketId"]) .index("by_ticket", ["ticketId"])
.index("by_ticket_agent", ["ticketId", "agentId"]), .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"]),
}); });

View file

@ -47,6 +47,8 @@ export const list = query({
); );
} }
const limited = args.limit ? filtered.slice(0, args.limit) : filtered; const limited = args.limit ? filtered.slice(0, args.limit) : filtered;
const categoryCache = new Map<string, Doc<"ticketCategories"> | null>();
const subcategoryCache = new Map<string, Doc<"ticketSubcategories"> | null>();
// hydrate requester and assignee // hydrate requester and assignee
const result = await Promise.all( const result = await Promise.all(
limited.map(async (t) => { 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 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 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; 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 { return {
id: t._id, id: t._id,
reference: t.reference, reference: t.reference,
@ -89,6 +111,8 @@ export const list = query({
tags: t.tags ?? [], tags: t.tags ?? [],
lastTimelineEntry: null, lastTimelineEntry: null,
metrics: null, metrics: null,
category: categorySummary,
subcategory: subcategorySummary,
workSummary: { workSummary: {
totalWorkedMs: t.totalWorkedMs ?? 0, totalWorkedMs: t.totalWorkedMs ?? 0,
activeSession: activeSession activeSession: activeSession
@ -115,6 +139,8 @@ export const getById = query({
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null; 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 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 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 const comments = await ctx.db
.query("ticketComments") .query("ticketComments")
.withIndex("by_ticket", (q) => q.eq("ticketId", id)) .withIndex("by_ticket", (q) => q.eq("ticketId", id))
@ -191,6 +217,19 @@ export const getById = query({
tags: t.tags ?? [], tags: t.tags ?? [],
lastTimelineEntry: null, lastTimelineEntry: null,
metrics: 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: { workSummary: {
totalWorkedMs: t.totalWorkedMs ?? 0, totalWorkedMs: t.totalWorkedMs ?? 0,
activeSession: activeSession activeSession: activeSession
@ -223,12 +262,22 @@ export const create = mutation({
channel: v.string(), channel: v.string(),
queueId: v.optional(v.id("queues")), queueId: v.optional(v.id("queues")),
requesterId: v.id("users"), requesterId: v.id("users"),
categoryId: v.id("ticketCategories"),
subcategoryId: v.id("ticketSubcategories"),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const subject = args.subject.trim(); const subject = args.subject.trim();
if (subject.length < 3) { if (subject.length < 3) {
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres"); 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) // compute next reference (simple monotonic counter per tenant)
const existing = await ctx.db const existing = await ctx.db
.query("tickets") .query("tickets")
@ -246,6 +295,8 @@ export const create = mutation({
priority: args.priority, priority: args.priority,
channel: args.channel, channel: args.channel,
queueId: args.queueId, queueId: args.queueId,
categoryId: args.categoryId,
subcategoryId: args.subcategoryId,
requesterId: args.requesterId, requesterId: args.requesterId,
assigneeId: undefined, assigneeId: undefined,
working: false, 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({ export const workSummary = query({
args: { ticketId: v.id("tickets") }, args: { ticketId: v.id("tickets") },
handler: async (ctx, { ticketId }) => { handler: async (ctx, { ticketId }) => {

View file

@ -26,14 +26,15 @@ import {
priorityStyles, priorityStyles,
priorityTriggerClass, priorityTriggerClass,
} from "@/components/tickets/priority-select" } from "@/components/tickets/priority-select"
import { CategorySelectFields } from "@/components/tickets/category-select"
export default function NewTicketPage() { export default function NewTicketPage() {
const router = useRouter() const router = useRouter()
const { userId } = useAuth() 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 create = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment) const addComment = useMutation(api.tickets.addComment)
const ensureDefaults = useMutation(api.bootstrap.ensureDefaults)
const [subject, setSubject] = useState("") const [subject, setSubject] = useState("")
const [summary, setSummary] = useState("") const [summary, setSummary] = useState("")
@ -43,6 +44,10 @@ export default function NewTicketPage() {
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [subjectError, setSubjectError] = useState<string | null>(null) const [subjectError, setSubjectError] = useState<string | null>(null)
const [categoryId, setCategoryId] = useState<string | null>(null)
const [subcategoryId, setSubcategoryId] = useState<string | null>(null)
const [categoryError, setCategoryError] = useState<string | null>(null)
const [subcategoryError, setSubcategoryError] = useState<string | null>(null)
const queueOptions = useMemo(() => queues.map((q) => q.name), [queues]) const queueOptions = useMemo(() => queues.map((q) => q.name), [queues])
@ -57,10 +62,18 @@ export default function NewTicketPage() {
} }
setSubjectError(null) setSubjectError(null)
if (!categoryId) {
setCategoryError("Selecione uma categoria.")
return
}
if (!subcategoryId) {
setSubcategoryError("Selecione uma categoria secundária.")
return
}
setLoading(true) setLoading(true)
toast.loading("Criando ticket...", { id: "create-ticket" }) toast.loading("Criando ticket...", { id: "create-ticket" })
try { try {
if (queues.length === 0) await ensureDefaults({ tenantId: DEFAULT_TENANT_ID })
const selQueue = queues.find((q) => q.name === queueName) const selQueue = queues.find((q) => q.name === queueName)
const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined
const id = await create({ const id = await create({
@ -71,6 +84,8 @@ export default function NewTicketPage() {
channel, channel,
queueId, queueId,
requesterId: userId as Id<"users">, requesterId: userId as Id<"users">,
categoryId: categoryId as Id<"ticketCategories">,
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
}) })
const plainDescription = description.replace(/<[^>]*>/g, "").trim() const plainDescription = description.replace(/<[^>]*>/g, "").trim()
if (plainDescription.length > 0) { if (plainDescription.length > 0) {
@ -135,6 +150,27 @@ export default function NewTicketPage() {
<label className="text-sm font-medium text-neutral-700">Descrição</label> <label className="text-sm font-medium text-neutral-700">Descrição</label>
<RichTextEditor value={description} onChange={setDescription} placeholder="Detalhe o problema, passos para reproduzir, links, etc." /> <RichTextEditor value={description} onChange={setDescription} placeholder="Detalhe o problema, passos para reproduzir, links, etc." />
</div> </div>
<div className="space-y-2">
<CategorySelectFields
tenantId={DEFAULT_TENANT_ID}
categoryId={categoryId}
subcategoryId={subcategoryId}
onCategoryChange={(value) => {
setCategoryId(value)
setCategoryError(null)
}}
onSubcategoryChange={(value) => {
setSubcategoryId(value)
setSubcategoryError(null)
}}
/>
{categoryError || subcategoryError ? (
<div className="text-xs font-medium text-red-500">
{categoryError ? <div>{categoryError}</div> : null}
{subcategoryError ? <div>{subcategoryError}</div> : null}
</div>
) : null}
</div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium text-neutral-700">Prioridade</span> <span className="text-sm font-medium text-neutral-700">Prioridade</span>

View file

@ -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 (
<div className={cn("grid gap-3 sm:grid-cols-2", className)}>
<div className="space-y-1.5">
<label className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconFolders className="size-3.5" /> {categoryLabel}
</label>
<Select
disabled={disabled || isLoading || categories.length === 0}
value={categoryId ?? undefined}
onValueChange={(value) => {
if (!value) return
onCategoryChange(value)
}}
>
<SelectTrigger className={triggerClass}>
<SelectValue placeholder={isLoading ? "Carregando..." : "Selecione"} />
</SelectTrigger>
<SelectContent className={contentClass}>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id} className={itemClass}>
<span className="flex items-center gap-2">
<IconFolders className="size-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-800">{category.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconFolder className="size-3.5" /> {subcategoryLabel}
</label>
<Select
disabled={disabled || secondaryOptions.length === 0}
value={subcategoryId ?? undefined}
onValueChange={(value) => {
if (!value) return
onSubcategoryChange(value)
}}
>
<SelectTrigger className={triggerClass}>
<SelectValue placeholder={secondaryOptions.length === 0 ? secondaryEmptyLabel : "Selecione"} />
</SelectTrigger>
<SelectContent className={contentClass}>
{secondaryOptions.map((option) => (
<SelectItem key={option.id} value={option.id} className={itemClass}>
<span className="flex items-center gap-2">
<IconFolder className="size-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-800">{option.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}

View file

@ -1,7 +1,7 @@
"use client" "use client"
import { z } from "zod" import { z } from "zod"
import { useState } from "react" import { useMemo, useState } from "react"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket" import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
@ -29,6 +29,7 @@ import {
priorityStyles, priorityStyles,
priorityTriggerClass, priorityTriggerClass,
} from "@/components/tickets/priority-select" } from "@/components/tickets/priority-select"
import { CategorySelectFields } from "@/components/tickets/category-select"
const schema = z.object({ const schema = z.object({
subject: z.string().min(3, "Informe um assunto"), 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"), priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"), channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
queueName: z.string().nullable().optional(), 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() { export function NewTicketDialog() {
@ -44,17 +47,29 @@ export function NewTicketDialog() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const form = useForm<z.infer<typeof schema>>({ const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema), 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", mode: "onTouched",
}) })
const { userId } = useAuth() 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 create = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment) const addComment = useMutation(api.tickets.addComment)
const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([]) const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
const priorityValue = form.watch("priority") as TicketPriority const priorityValue = form.watch("priority") as TicketPriority
const channelValue = form.watch("channel") const channelValue = form.watch("channel")
const queueValue = form.watch("queueName") ?? "NONE" 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 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" 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, channel: values.channel,
queueId: sel?.id as Id<"queues"> | undefined, queueId: sel?.id as Id<"queues"> | undefined,
requesterId: userId as Id<"users">, 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 hasDescription = (values.description ?? "").replace(/<[^>]*>/g, "").trim().length > 0
const bodyHtml = hasDescription ? (values.description as string) : (values.summary || "") const bodyHtml = hasDescription ? (values.description as string) : (values.summary || "")
@ -95,7 +112,7 @@ export function NewTicketDialog() {
setAttachments([]) setAttachments([])
// Navegar para o ticket recém-criado // Navegar para o ticket recém-criado
window.location.href = `/tickets/${id}` window.location.href = `/tickets/${id}`
} catch (err) { } catch {
toast.error("Não foi possível criar o ticket.", { id: "new-ticket" }) toast.error("Não foi possível criar o ticket.", { id: "new-ticket" })
} finally { } finally {
setLoading(false) setLoading(false)
@ -142,6 +159,27 @@ export function NewTicketDialog() {
<Dropzone onUploaded={(files) => setAttachments((prev) => [...prev, ...files])} /> <Dropzone onUploaded={(files) => setAttachments((prev) => [...prev, ...files])} />
<FieldError className="mt-1">Formatos comuns de imagem e documentos são aceitos.</FieldError> <FieldError className="mt-1">Formatos comuns de imagem e documentos são aceitos.</FieldError>
</Field> </Field>
<Field>
<CategorySelectFields
tenantId={DEFAULT_TENANT_ID}
categoryId={categoryIdValue || null}
subcategoryId={subcategoryIdValue || null}
onCategoryChange={(value) => {
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 ? (
<FieldError className="mt-1 space-y-0.5">
<>
{form.formState.errors.categoryId?.message ? <div>{form.formState.errors.categoryId?.message}</div> : null}
{form.formState.errors.subcategoryId?.message ? <div>{form.formState.errors.subcategoryId?.message}</div> : null}
</>
</FieldError>
) : null}
</Field>
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
<Field> <Field>
<FieldLabel>Prioridade</FieldLabel> <FieldLabel>Prioridade</FieldLabel>

View file

@ -10,20 +10,25 @@ import { useAuth } from "@/lib/auth-client"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { toast } from "sonner" 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" import { cn } from "@/lib/utils"
export const priorityStyles: Record<TicketPriority, { label: string; badgeClass: string }> = { export const priorityStyles: Record<TicketPriority, { label: string; badgeClass: string }> = {
LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" }, LOW: { label: "Baixa", badgeClass: "bg-slate-100 text-slate-700" },
MEDIUM: { label: "Média", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" }, MEDIUM: { label: "Média", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
HIGH: { label: "Alta", badgeClass: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" }, HIGH: { label: "Alta", badgeClass: "bg-[#fde8d1] text-[#7d3b05]" },
URGENT: { label: "Urgente", badgeClass: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" }, 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" 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" 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 }) { export function PriorityIcon({ value }: { value: TicketPriority }) {
if (value === "LOW") return <ArrowDown className={iconClass} /> if (value === "LOW") return <ArrowDown className={iconClass} />
@ -55,11 +60,12 @@ export function PrioritySelect({ ticketId, value }: { ticketId: string; value: T
} }
}} }}
> >
<SelectTrigger className={priorityTriggerClass}> <SelectTrigger className={headerTriggerClass} aria-label="Atualizar prioridade">
<SelectValue> <SelectValue asChild>
<Badge className={cn(priorityBadgeClass, priorityStyles[priority]?.badgeClass)}> <Badge className={cn(priorityBadgeClass, priorityStyles[priority]?.badgeClass)}>
<PriorityIcon value={priority} /> <PriorityIcon value={priority} />
{priorityStyles[priority]?.label ?? priority} {priorityStyles[priority]?.label ?? priority}
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
</Badge> </Badge>
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>

View file

@ -11,19 +11,22 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { toast } from "sonner" import { toast } from "sonner"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ChevronDown } from "lucide-react"
const statusStyles: Record<TicketStatus, { label: string; badgeClass: string }> = { const statusStyles: Record<TicketStatus, { label: string; badgeClass: string }> = {
NEW: { label: "Novo", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" }, NEW: { label: "Novo", badgeClass: "bg-slate-100 text-slate-700" },
OPEN: { label: "Aberto", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" }, OPEN: { label: "Aberto", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
PENDING: { label: "Pendente", badgeClass: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" }, PENDING: { label: "Pendente", badgeClass: "bg-[#fdebd6] text-[#7b4107]" },
ON_HOLD: { label: "Em espera", badgeClass: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" }, ON_HOLD: { label: "Em espera", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
RESOLVED: { label: "Resolvido", badgeClass: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" }, RESOLVED: { label: "Resolvido", badgeClass: "bg-[#dcf4eb] text-[#1f6a45]" },
CLOSED: { label: "Fechado", badgeClass: "border border-slate-200 bg-slate-200 text-slate-700" }, 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 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 }) { export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) {
const updateStatus = useMutation(api.tickets.updateStatus) const updateStatus = useMutation(api.tickets.updateStatus)
@ -48,10 +51,11 @@ export function StatusSelect({ ticketId, value }: { ticketId: string; value: Tic
} }
}} }}
> >
<SelectTrigger className={triggerClass}> <SelectTrigger className={triggerClass} aria-label="Atualizar status">
<SelectValue> <SelectValue asChild>
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass)}> <Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass)}>
{statusStyles[status]?.label ?? status} {statusStyles[status]?.label ?? status}
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
</Badge> </Badge>
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>

View file

@ -21,13 +21,14 @@ import { StatusSelect } from "@/components/tickets/status-select"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { CategorySelectFields } from "@/components/tickets/category-select"
interface TicketHeaderProps { interface TicketHeaderProps {
ticket: TicketWithDetails ticket: TicketWithDetails
} }
const cardClass = "relative space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm" 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 = 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" "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 = const pauseButtonClass =
@ -64,6 +65,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const updateSummary = useMutation(api.tickets.updateSummary) const updateSummary = useMutation(api.tickets.updateSummary)
const startWork = useMutation(api.tickets.startWork) const startWork = useMutation(api.tickets.startWork)
const pauseWork = useMutation(api.tickets.pauseWork) 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 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 queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
const [status] = useState<TicketStatus>(ticket.status) const [status] = useState<TicketStatus>(ticket.status)
@ -79,6 +81,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [subject, setSubject] = useState(ticket.subject) const [subject, setSubject] = useState(ticket.subject)
const [summary, setSummary] = useState(ticket.summary ?? "") 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( const dirty = useMemo(
() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""), () => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""),
[subject, summary, ticket.subject, ticket.summary] [subject, summary, ticket.subject, ticket.summary]
@ -107,6 +114,51 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setEditing(false) 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(() => { const workSummary = useMemo(() => {
if (workSummaryRemote !== undefined) return workSummaryRemote ?? null if (workSummaryRemote !== undefined) return workSummaryRemote ?? null
if (!ticket.workSummary) return null if (!ticket.workSummary) return null
@ -146,7 +198,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
return ( return (
<div className={cardClass}> <div className={cardClass}>
<div className="absolute right-6 top-6 flex items-center gap-2"> <div className="absolute right-6 top-6 flex items-center gap-3">
{workSummary ? (
<Badge className="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">
Tempo total: {formattedTotalWorked}
</Badge>
) : null}
{!editing ? ( {!editing ? (
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}> <Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
Editar Editar
@ -201,9 +258,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</div> </div>
{workSummary ? ( {workSummary ? (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge className="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">
Tempo total: {formattedTotalWorked}
</Badge>
{isPlaying ? ( {isPlaying ? (
<Badge className="inline-flex items-center gap-1 rounded-full border border-black bg-black px-3 py-1 text-xs font-semibold text-white"> <Badge className="inline-flex items-center gap-1 rounded-full border border-black bg-black px-3 py-1 text-xs font-semibold text-white">
Sessão atual: {formattedCurrentSession} Sessão atual: {formattedCurrentSession}
@ -246,6 +300,40 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</div> </div>
<Separator className="bg-slate-200" /> <Separator className="bg-slate-200" />
<div className="grid gap-4 text-sm text-neutral-600 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 text-sm text-neutral-600 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-2 sm:col-span-2 lg:col-span-3">
<span className={sectionLabelClass}>Categorias</span>
<CategorySelectFields
tenantId={ticket.tenantId}
autoSelectFirst={!ticket.category}
categoryId={categorySelection.categoryId || null}
subcategoryId={categorySelection.subcategoryId || null}
onCategoryChange={(value) => {
setCategorySelection((prev) => ({ ...prev, categoryId: value }))
}}
onSubcategoryChange={(value) => {
setCategorySelection((prev) => ({ ...prev, subcategoryId: value }))
}}
/>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
className={startButtonClass}
onClick={handleSaveCategory}
disabled={!categoryDirty || savingCategory}
>
{savingCategory ? "Salvando..." : "Salvar"}
</Button>
<Button
size="sm"
variant="ghost"
className="text-sm font-semibold text-neutral-700"
onClick={handleResetCategory}
disabled={savingCategory || !categoryDirty}
>
Cancelar
</Button>
</div>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Solicitante</span> <span className={sectionLabelClass}>Solicitante</span>
<span className={sectionValueClass}>{ticket.requester.name}</span> <span className={sectionValueClass}>{ticket.requester.name}</span>

View file

@ -3,6 +3,7 @@ import type { ComponentType } from "react"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { import {
IconClockHour4, IconClockHour4,
IconFolders,
IconNote, IconNote,
IconPaperclip, IconPaperclip,
IconSquareCheck, IconSquareCheck,
@ -26,6 +27,7 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
QUEUE_CHANGED: IconSquareCheck, QUEUE_CHANGED: IconSquareCheck,
PRIORITY_CHANGED: IconSquareCheck, PRIORITY_CHANGED: IconSquareCheck,
ATTACHMENT_REMOVED: IconPaperclip, ATTACHMENT_REMOVED: IconPaperclip,
CATEGORY_CHANGED: IconFolders,
} }
const timelineLabels: Record<string, string> = { const timelineLabels: Record<string, string> = {
@ -40,6 +42,7 @@ const timelineLabels: Record<string, string> = {
QUEUE_CHANGED: "Fila alterada", QUEUE_CHANGED: "Fila alterada",
PRIORITY_CHANGED: "Prioridade alterada", PRIORITY_CHANGED: "Prioridade alterada",
ATTACHMENT_REMOVED: "Anexo removido", ATTACHMENT_REMOVED: "Anexo removido",
CATEGORY_CHANGED: "Categoria alterada",
} }
interface TicketTimelineProps { interface TicketTimelineProps {
@ -110,6 +113,8 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
from?: string from?: string
attachmentName?: string attachmentName?: string
sessionDurationMs?: number sessionDurationMs?: number
categoryName?: string
subcategoryName?: string
} }
let message: string | null = null let message: string | null = null
@ -143,6 +148,11 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
if (entry.type === "WORK_PAUSED" && typeof payload.sessionDurationMs === "number") { if (entry.type === "WORK_PAUSED" && typeof payload.sessionDurationMs === "number") {
message = `Tempo registrado: ${formatDuration(payload.sessionDurationMs)}` 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 if (!message) return null
return ( return (

View file

@ -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 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 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 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" const tableRowClass = "group border-b border-slate-100 text-sm transition-colors hover:bg-slate-100/70 last:border-none"
function formatDuration(ms?: number) { function formatDuration(ms?: number) {
@ -169,6 +170,12 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</span> </span>
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-500"> <div className="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
<span className="font-semibold text-neutral-700">{ticket.requester.name}</span> <span className="font-semibold text-neutral-700">{ticket.requester.name}</span>
{ticket.category ? (
<Badge className={categoryBadgeClass}>
{ticket.category.name}
{ticket.subcategory ? `${ticket.subcategory.name}` : ""}
</Badge>
) : null}
{ticket.tags?.map((tag) => ( {ticket.tags?.map((tag) => (
<Badge key={tag} className={tagBadgeClass}> <Badge key={tag} className={tagBadgeClass}>
{tag} {tag}

View file

@ -11,18 +11,41 @@ const Toaster = ({ ...props }: ToasterProps) => {
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
toastOptions={{ 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: { classNames: {
toast: "border border-black bg-black text-white shadow-lg rounded-xl px-4 py-3 text-sm font-semibold", icon: "size-[14px] text-white [&>svg]:size-[14px] [&>svg]:text-white",
success: "border border-black bg-black text-white", },
error: "border border-black bg-black text-white", descriptionClassName: "text-white/80",
info: "border border-black bg-black text-white", actionButtonClassName: "bg-white text-black border border-black rounded-lg",
warning: "border border-black bg-black text-white", cancelButtonClassName: "bg-transparent text-white border border-white/40 rounded-lg",
loading: "border border-black bg-black text-white", iconTheme: {
title: "font-medium", primary: "#ffffff",
description: "text-white/80", secondary: "#000000",
icon: "text-[#00e8ff]", },
actionButton: "bg-white text-black border border-black rounded-lg", success: {
cancelButton: "bg-transparent text-white border border-white/40 rounded-lg", 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={ style={

View file

@ -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]
)
}

View file

@ -32,6 +32,21 @@ const serverTicketSchema = z.object({
tags: z.array(z.string()).default([]).optional(), tags: z.array(z.string()).default([]).optional(),
lastTimelineEntry: z.string().nullable().optional(), lastTimelineEntry: z.string().nullable().optional(),
metrics: z.any().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 workSummary: z
.object({ .object({
totalWorkedMs: z.number(), totalWorkedMs: z.number(),
@ -82,6 +97,8 @@ export function mapTicketFromServer(input: unknown) {
const s = serverTicketSchema.parse(input); const s = serverTicketSchema.parse(input);
const ui = { const ui = {
...s, ...s,
category: s.category ?? undefined,
subcategory: s.subcategory ?? undefined,
lastTimelineEntry: s.lastTimelineEntry ?? undefined, lastTimelineEntry: s.lastTimelineEntry ?? undefined,
updatedAt: new Date(s.updatedAt), updatedAt: new Date(s.updatedAt),
createdAt: new Date(s.createdAt), createdAt: new Date(s.createdAt),
@ -112,6 +129,8 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
const ui = { const ui = {
...s, ...s,
customFields: (s.customFields ?? {}) as Record<string, unknown>, customFields: (s.customFields ?? {}) as Record<string, unknown>,
category: s.category ?? undefined,
subcategory: s.subcategory ?? undefined,
lastTimelineEntry: s.lastTimelineEntry ?? undefined, lastTimelineEntry: s.lastTimelineEntry ?? undefined,
updatedAt: new Date(s.updatedAt), updatedAt: new Date(s.updatedAt),
createdAt: new Date(s.createdAt), createdAt: new Date(s.createdAt),

View file

@ -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<typeof ticketSubcategorySchema>
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<typeof ticketCategorySchema>

View file

@ -36,6 +36,19 @@ export const userSummarySchema = z.object({
}) })
export type UserSummary = z.infer<typeof userSummarySchema> export type UserSummary = z.infer<typeof userSummarySchema>
export const ticketCategorySummarySchema = z.object({
id: z.string(),
name: z.string(),
})
export type TicketCategorySummary = z.infer<typeof ticketCategorySummarySchema>
export const ticketSubcategorySummarySchema = z.object({
id: z.string(),
name: z.string(),
categoryId: z.string().optional(),
})
export type TicketSubcategorySummary = z.infer<typeof ticketSubcategorySummarySchema>
export const ticketCommentSchema = z.object({ export const ticketCommentSchema = z.object({
id: z.string(), id: z.string(),
author: userSummarySchema, author: userSummarySchema,
@ -98,6 +111,8 @@ export const ticketSchema = z.object({
timeOpenedMinutes: z.number().nullable(), timeOpenedMinutes: z.number().nullable(),
}) })
.nullable(), .nullable(),
category: ticketCategorySummarySchema.nullable().optional(),
subcategory: ticketSubcategorySummarySchema.nullable().optional(),
workSummary: z workSummary: z
.object({ .object({
totalWorkedMs: z.number(), totalWorkedMs: z.number(),