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:
parent
55511f3a0e
commit
fab1cbe476
17 changed files with 1121 additions and 42 deletions
2
web/convex/_generated/api.d.ts
vendored
2
web/convex/_generated/api.d.ts
vendored
|
|
@ -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
510
web/convex/categories.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
|
|
@ -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"]),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
135
web/src/components/tickets/category-select.tsx
Normal file
135
web/src/components/tickets/category-select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -11,18 +11,41 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg",
|
||||
style: {
|
||||
background: "#000",
|
||||
color: "#fff",
|
||||
border: "1px solid #000",
|
||||
},
|
||||
classNames: {
|
||||
toast: "border border-black bg-black text-white shadow-lg rounded-xl px-4 py-3 text-sm font-semibold",
|
||||
success: "border border-black bg-black text-white",
|
||||
error: "border border-black bg-black text-white",
|
||||
info: "border border-black bg-black text-white",
|
||||
warning: "border border-black bg-black text-white",
|
||||
loading: "border border-black bg-black text-white",
|
||||
title: "font-medium",
|
||||
description: "text-white/80",
|
||||
icon: "text-[#00e8ff]",
|
||||
actionButton: "bg-white text-black border border-black rounded-lg",
|
||||
cancelButton: "bg-transparent text-white border border-white/40 rounded-lg",
|
||||
icon: "size-[14px] text-white [&>svg]:size-[14px] [&>svg]:text-white",
|
||||
},
|
||||
descriptionClassName: "text-white/80",
|
||||
actionButtonClassName: "bg-white text-black border border-black rounded-lg",
|
||||
cancelButtonClassName: "bg-transparent text-white border border-white/40 rounded-lg",
|
||||
iconTheme: {
|
||||
primary: "#ffffff",
|
||||
secondary: "#000000",
|
||||
},
|
||||
success: {
|
||||
className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg",
|
||||
style: { background: "#000", color: "#fff", border: "1px solid #000" },
|
||||
},
|
||||
error: {
|
||||
className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg",
|
||||
style: { background: "#000", color: "#fff", border: "1px solid #000" },
|
||||
},
|
||||
info: {
|
||||
className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg",
|
||||
style: { background: "#000", color: "#fff", border: "1px solid #000" },
|
||||
},
|
||||
warning: {
|
||||
className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg",
|
||||
style: { background: "#000", color: "#fff", border: "1px solid #000" },
|
||||
},
|
||||
loading: {
|
||||
className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg",
|
||||
style: { background: "#000", color: "#fff", border: "1px solid #000" },
|
||||
},
|
||||
}}
|
||||
style={
|
||||
|
|
|
|||
35
web/src/hooks/use-ticket-categories.ts
Normal file
35
web/src/hooks/use-ticket-categories.ts
Normal 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]
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
20
web/src/lib/schemas/category.ts
Normal file
20
web/src/lib/schemas/category.ts
Normal 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>
|
||||
|
|
@ -35,6 +35,19 @@ export const userSummarySchema = z.object({
|
|||
teams: z.array(z.string()).default([]),
|
||||
})
|
||||
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(),
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue