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
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)
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue