Merge pull request #7 from esdrasrenan/feat/convex-tickets-core

Feat/convex tickets core
This commit is contained in:
esdrasrenan 2025-10-05 00:01:35 -03:00 committed by GitHub
commit 5627d43639
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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 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;

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(),
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"]),
});

View file

@ -47,6 +47,8 @@ export const list = query({
);
}
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
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 }) => {

View file

@ -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<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])
@ -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() {
<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." />
</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="space-y-2">
<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"
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<z.infer<typeof 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",
})
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<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
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() {
<Dropzone onUploaded={(files) => setAttachments((prev) => [...prev, ...files])} />
<FieldError className="mt-1">Formatos comuns de imagem e documentos são aceitos.</FieldError>
</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">
<Field>
<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 { 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<TicketPriority, { label: string; badgeClass: string }> = {
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 <ArrowDown className={iconClass} />
@ -55,11 +60,12 @@ export function PrioritySelect({ ticketId, value }: { ticketId: string; value: T
}
}}
>
<SelectTrigger className={priorityTriggerClass}>
<SelectValue>
<SelectTrigger className={headerTriggerClass} aria-label="Atualizar prioridade">
<SelectValue asChild>
<Badge className={cn(priorityBadgeClass, priorityStyles[priority]?.badgeClass)}>
<PriorityIcon value={priority} />
{priorityStyles[priority]?.label ?? priority}
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
</Badge>
</SelectValue>
</SelectTrigger>

View file

@ -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<TicketStatus, { label: string; badgeClass: string }> = {
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
}
}}
>
<SelectTrigger className={triggerClass}>
<SelectValue>
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
<SelectValue asChild>
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass)}>
{statusStyles[status]?.label ?? status}
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
</Badge>
</SelectValue>
</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 { 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<TicketStatus>(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 (
<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 ? (
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
Editar
@ -201,9 +258,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</div>
{workSummary ? (
<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 ? (
<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}
@ -246,6 +300,40 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</div>
<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="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">
<span className={sectionLabelClass}>Solicitante</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 {
IconClockHour4,
IconFolders,
IconNote,
IconPaperclip,
IconSquareCheck,
@ -26,6 +27,7 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
QUEUE_CHANGED: IconSquareCheck,
PRIORITY_CHANGED: IconSquareCheck,
ATTACHMENT_REMOVED: IconPaperclip,
CATEGORY_CHANGED: IconFolders,
}
const timelineLabels: Record<string, string> = {
@ -40,6 +42,7 @@ const timelineLabels: Record<string, string> = {
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 (

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 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) {
</span>
<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>
{ticket.category ? (
<Badge className={categoryBadgeClass}>
{ticket.category.name}
{ticket.subcategory ? `${ticket.subcategory.name}` : ""}
</Badge>
) : null}
{ticket.tags?.map((tag) => (
<Badge key={tag} className={tagBadgeClass}>
{tag}

View file

@ -11,18 +11,41 @@ const Toaster = ({ ...props }: ToasterProps) => {
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 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: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg",
style: { background: "#000", color: "#fff", border: "1px solid #000" },
},
error: {
className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg",
style: { background: "#000", color: "#fff", border: "1px solid #000" },
},
info: {
className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg",
style: { background: "#000", color: "#fff", border: "1px solid #000" },
},
warning: {
className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg",
style: { background: "#000", color: "#fff", border: "1px solid #000" },
},
loading: {
className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg",
style: { background: "#000", color: "#fff", border: "1px solid #000" },
},
}}
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(),
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<string, unknown>,
category: s.category ?? undefined,
subcategory: s.subcategory ?? undefined,
lastTimelineEntry: s.lastTimelineEntry ?? undefined,
updatedAt: new Date(s.updatedAt),
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 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({
id: z.string(),
author: userSummarySchema,
@ -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(),