diff --git a/web/convex/_generated/api.d.ts b/web/convex/_generated/api.d.ts index 58ea5e6..7f415c7 100644 --- a/web/convex/_generated/api.d.ts +++ b/web/convex/_generated/api.d.ts @@ -12,6 +12,7 @@ import type * as bootstrap from "../bootstrap.js"; import type * as categories from "../categories.js"; import type * as fields from "../fields.js"; import type * as files from "../files.js"; +import type * as invites from "../invites.js"; import type * as queues from "../queues.js"; import type * as rbac from "../rbac.js"; import type * as reports from "../reports.js"; @@ -40,6 +41,7 @@ declare const fullApi: ApiFromModules<{ categories: typeof categories; fields: typeof fields; files: typeof files; + invites: typeof invites; queues: typeof queues; rbac: typeof rbac; reports: typeof reports; diff --git a/web/convex/categories.ts b/web/convex/categories.ts index 9c74b74..9f86853 100644 --- a/web/convex/categories.ts +++ b/web/convex/categories.ts @@ -3,6 +3,8 @@ import type { MutationCtx } from "./_generated/server" import { ConvexError, v } from "convex/values" import { Id } from "./_generated/dataModel" +import { requireAdmin } from "./rbac" + type CategorySeed = { name: string description?: string @@ -295,10 +297,13 @@ export const ensureDefaults = mutation({ export const createCategory = mutation({ args: { tenantId: v.string(), + actorId: v.id("users"), name: v.string(), description: v.optional(v.string()), + secondary: v.optional(v.array(v.string())), }, - handler: async (ctx, { tenantId, name, description }) => { + handler: async (ctx, { tenantId, actorId, name, description, secondary }) => { + await requireAdmin(ctx, actorId, tenantId) const trimmed = name.trim() if (trimmed.length < 2) { throw new ConvexError("Informe um nome válido para a categoria") @@ -321,6 +326,31 @@ export const createCategory = mutation({ createdAt: now, updatedAt: now, }) + + if (secondary?.length) { + let subOrder = 0 + for (const item of secondary) { + const value = item.trim() + if (value.length < 2) continue + const subSlug = await ensureUniqueSlug( + ctx, + "ticketSubcategories", + tenantId, + slugify(value), + { categoryId: id } + ) + await ctx.db.insert("ticketSubcategories", { + tenantId, + categoryId: id, + name: value, + slug: subSlug, + order: subOrder, + createdAt: now, + updatedAt: now, + }) + subOrder += 1 + } + } return id }, }) @@ -329,10 +359,12 @@ export const updateCategory = mutation({ args: { categoryId: v.id("ticketCategories"), tenantId: v.string(), + actorId: v.id("users"), name: v.string(), description: v.optional(v.string()), }, - handler: async (ctx, { categoryId, tenantId, name, description }) => { + handler: async (ctx, { categoryId, tenantId, actorId, name, description }) => { + await requireAdmin(ctx, actorId, tenantId) const category = await ctx.db.get(categoryId) if (!category || category.tenantId !== tenantId) { throw new ConvexError("Categoria não encontrada") @@ -354,9 +386,11 @@ export const deleteCategory = mutation({ args: { categoryId: v.id("ticketCategories"), tenantId: v.string(), + actorId: v.id("users"), transferTo: v.optional(v.id("ticketCategories")), }, - handler: async (ctx, { categoryId, tenantId, transferTo }) => { + handler: async (ctx, { categoryId, tenantId, actorId, transferTo }) => { + await requireAdmin(ctx, actorId, tenantId) const category = await ctx.db.get(categoryId) if (!category || category.tenantId !== tenantId) { throw new ConvexError("Categoria não encontrada") @@ -412,10 +446,12 @@ export const deleteCategory = mutation({ export const createSubcategory = mutation({ args: { tenantId: v.string(), + actorId: v.id("users"), categoryId: v.id("ticketCategories"), name: v.string(), }, - handler: async (ctx, { tenantId, categoryId, name }) => { + handler: async (ctx, { tenantId, actorId, categoryId, name }) => { + await requireAdmin(ctx, actorId, tenantId) const category = await ctx.db.get(categoryId) if (!category || category.tenantId !== tenantId) { throw new ConvexError("Categoria não encontrada") @@ -449,10 +485,12 @@ export const createSubcategory = mutation({ export const updateSubcategory = mutation({ args: { tenantId: v.string(), + actorId: v.id("users"), subcategoryId: v.id("ticketSubcategories"), name: v.string(), }, - handler: async (ctx, { tenantId, subcategoryId, name }) => { + handler: async (ctx, { tenantId, actorId, subcategoryId, name }) => { + await requireAdmin(ctx, actorId, tenantId) const subcategory = await ctx.db.get(subcategoryId) if (!subcategory || subcategory.tenantId !== tenantId) { throw new ConvexError("Subcategoria não encontrada") @@ -471,10 +509,12 @@ export const updateSubcategory = mutation({ export const deleteSubcategory = mutation({ args: { tenantId: v.string(), + actorId: v.id("users"), subcategoryId: v.id("ticketSubcategories"), transferTo: v.optional(v.id("ticketSubcategories")), }, - handler: async (ctx, { tenantId, subcategoryId, transferTo }) => { + handler: async (ctx, { tenantId, actorId, subcategoryId, transferTo }) => { + await requireAdmin(ctx, actorId, tenantId) const subcategory = await ctx.db.get(subcategoryId) if (!subcategory || subcategory.tenantId !== tenantId) { throw new ConvexError("Subcategoria não encontrada") diff --git a/web/convex/fields.ts b/web/convex/fields.ts index b4009db..fa13cb5 100644 --- a/web/convex/fields.ts +++ b/web/convex/fields.ts @@ -3,7 +3,7 @@ import type { MutationCtx, QueryCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; -import { requireAdmin } from "./rbac"; +import { requireAdmin, requireUser } from "./rbac"; const FIELD_TYPES = ["text", "number", "select", "date", "boolean"] as const; @@ -63,6 +63,30 @@ export const list = query({ }, }); +export const listForTenant = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireUser(ctx, viewerId, tenantId); + const fields = await ctx.db + .query("ticketFields") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + .collect(); + + return fields + .sort((a, b) => a.order - b.order) + .map((field) => ({ + id: field._id, + key: field.key, + label: field.label, + description: field.description ?? "", + type: field.type as FieldType, + required: field.required, + options: field.options ?? [], + order: field.order, + })); + }, +}); + export const create = mutation({ args: { tenantId: v.string(), diff --git a/web/convex/invites.ts b/web/convex/invites.ts new file mode 100644 index 0000000..54aebc4 --- /dev/null +++ b/web/convex/invites.ts @@ -0,0 +1,115 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; + +import { requireAdmin } from "./rbac"; + +export const list = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireAdmin(ctx, viewerId, tenantId); + + const invites = await ctx.db + .query("userInvites") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + return invites + .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)) + .map((invite) => ({ + id: invite._id, + inviteId: invite.inviteId, + email: invite.email, + name: invite.name ?? null, + role: invite.role, + status: invite.status, + token: invite.token, + expiresAt: invite.expiresAt, + createdAt: invite.createdAt, + createdById: invite.createdById ?? null, + acceptedAt: invite.acceptedAt ?? null, + acceptedById: invite.acceptedById ?? null, + revokedAt: invite.revokedAt ?? null, + revokedById: invite.revokedById ?? null, + revokedReason: invite.revokedReason ?? null, + })); + }, +}); + +export const sync = mutation({ + args: { + tenantId: v.string(), + inviteId: v.string(), + email: v.string(), + name: v.optional(v.string()), + role: v.string(), + status: v.string(), + token: v.string(), + expiresAt: v.number(), + createdAt: v.number(), + createdById: v.optional(v.string()), + acceptedAt: v.optional(v.number()), + acceptedById: v.optional(v.string()), + revokedAt: v.optional(v.number()), + revokedById: v.optional(v.string()), + revokedReason: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("userInvites") + .withIndex("by_invite", (q) => q.eq("tenantId", args.tenantId).eq("inviteId", args.inviteId)) + .first(); + + if (!existing) { + const id = await ctx.db.insert("userInvites", { + tenantId: args.tenantId, + inviteId: args.inviteId, + email: args.email, + name: args.name, + role: args.role, + status: args.status, + token: args.token, + expiresAt: args.expiresAt, + createdAt: args.createdAt, + createdById: args.createdById, + acceptedAt: args.acceptedAt, + acceptedById: args.acceptedById, + revokedAt: args.revokedAt, + revokedById: args.revokedById, + revokedReason: args.revokedReason, + }); + return await ctx.db.get(id); + } + + await ctx.db.patch(existing._id, { + email: args.email, + name: args.name, + role: args.role, + status: args.status, + token: args.token, + expiresAt: args.expiresAt, + createdAt: args.createdAt, + createdById: args.createdById, + acceptedAt: args.acceptedAt, + acceptedById: args.acceptedById, + revokedAt: args.revokedAt, + revokedById: args.revokedById, + revokedReason: args.revokedReason, + }); + + return await ctx.db.get(existing._id); + }, +}); + +export const remove = mutation({ + args: { tenantId: v.string(), inviteId: v.string() }, + handler: async (ctx, { tenantId, inviteId }) => { + const existing = await ctx.db + .query("userInvites") + .withIndex("by_invite", (q) => q.eq("tenantId", tenantId).eq("inviteId", inviteId)) + .first(); + + if (existing) { + await ctx.db.delete(existing._id); + } + }, +}); diff --git a/web/convex/schema.ts b/web/convex/schema.ts index e3c5a83..5a3a77a 100644 --- a/web/convex/schema.ts +++ b/web/convex/schema.ts @@ -60,6 +60,18 @@ export default defineSchema({ updatedAt: v.number(), createdAt: v.number(), tags: v.optional(v.array(v.string())), + customFields: v.optional( + v.array( + v.object({ + fieldId: v.id("ticketFields"), + fieldKey: v.string(), + label: v.string(), + type: v.string(), + value: v.any(), + displayValue: v.optional(v.string()), + }) + ) + ), totalWorkedMs: v.optional(v.number()), activeSessionId: v.optional(v.id("ticketWorkSessions")), }) @@ -153,4 +165,25 @@ export default defineSchema({ .index("by_tenant_key", ["tenantId", "key"]) .index("by_tenant_order", ["tenantId", "order"]) .index("by_tenant", ["tenantId"]), + + userInvites: defineTable({ + tenantId: v.string(), + inviteId: v.string(), + email: v.string(), + name: v.optional(v.string()), + role: v.string(), + status: v.string(), + token: v.string(), + expiresAt: v.number(), + createdAt: v.number(), + createdById: v.optional(v.string()), + acceptedAt: v.optional(v.number()), + acceptedById: v.optional(v.string()), + revokedAt: v.optional(v.number()), + revokedById: v.optional(v.string()), + revokedReason: v.optional(v.string()), + }) + .index("by_tenant", ["tenantId"]) + .index("by_token", ["tenantId", "token"]) + .index("by_invite", ["tenantId", "inviteId"]), }); diff --git a/web/convex/tickets.ts b/web/convex/tickets.ts index a40f7cb..e74b48b 100644 --- a/web/convex/tickets.ts +++ b/web/convex/tickets.ts @@ -1,4 +1,5 @@ import { mutation, query } from "./_generated/server"; +import type { MutationCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import { Id, type Doc } from "./_generated/dataModel"; @@ -37,6 +38,136 @@ function normalizeTeams(teams?: string[] | null): string[] { return teams.map((team) => renameQueueString(team) ?? team); } +type CustomFieldInput = { + fieldId: Id<"ticketFields">; + value: unknown; +}; + +type NormalizedCustomField = { + fieldId: Id<"ticketFields">; + fieldKey: string; + label: string; + type: string; + value: unknown; + displayValue?: string; +}; + +function coerceCustomFieldValue(field: Doc<"ticketFields">, raw: unknown): { value: unknown; displayValue?: string } { + switch (field.type) { + case "text": + return { value: String(raw).trim() }; + case "number": { + const value = typeof raw === "number" ? raw : Number(String(raw).replace(",", ".")); + if (!Number.isFinite(value)) { + throw new ConvexError(`Valor numérico inválido para o campo ${field.label}`); + } + return { value }; + } + case "date": { + if (typeof raw === "number") { + if (!Number.isFinite(raw)) { + throw new ConvexError(`Data inválida para o campo ${field.label}`); + } + return { value: raw }; + } + const parsed = Date.parse(String(raw)); + if (!Number.isFinite(parsed)) { + throw new ConvexError(`Data inválida para o campo ${field.label}`); + } + return { value: parsed }; + } + case "boolean": { + if (typeof raw === "boolean") { + return { value: raw }; + } + if (typeof raw === "string") { + const normalized = raw.toLowerCase(); + if (normalized === "true" || normalized === "1") return { value: true }; + if (normalized === "false" || normalized === "0") return { value: false }; + } + throw new ConvexError(`Valor inválido para o campo ${field.label}`); + } + case "select": { + if (!field.options || field.options.length === 0) { + throw new ConvexError(`Campo ${field.label} sem opções configuradas`); + } + const value = String(raw); + const option = field.options.find((opt) => opt.value === value); + if (!option) { + throw new ConvexError(`Seleção inválida para o campo ${field.label}`); + } + return { value: option.value, displayValue: option.label ?? option.value }; + } + default: + return { value: raw }; + } +} + +async function normalizeCustomFieldValues( + ctx: Pick, + tenantId: string, + inputs: CustomFieldInput[] | undefined +): Promise { + const definitions = await ctx.db + .query("ticketFields") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + if (!definitions.length) { + if (inputs && inputs.length > 0) { + throw new ConvexError("Nenhum campo personalizado configurado para este tenant"); + } + return []; + } + + const provided = new Map, unknown>(); + for (const entry of inputs ?? []) { + provided.set(entry.fieldId, entry.value); + } + + const normalized: NormalizedCustomField[] = []; + + for (const definition of definitions.sort((a, b) => a.order - b.order)) { + const raw = provided.has(definition._id) ? provided.get(definition._id) : undefined; + const isMissing = + raw === undefined || + raw === null || + (typeof raw === "string" && raw.trim().length === 0); + + if (isMissing) { + if (definition.required) { + throw new ConvexError(`Preencha o campo obrigatório: ${definition.label}`); + } + continue; + } + + const { value, displayValue } = coerceCustomFieldValue(definition, raw); + normalized.push({ + fieldId: definition._id, + fieldKey: definition.key, + label: definition.label, + type: definition.type, + value, + displayValue, + }); + } + + return normalized; +} + +function mapCustomFieldsToRecord(entries: NormalizedCustomField[] | undefined) { + if (!entries || entries.length === 0) return {}; + return entries.reduce>((acc, entry) => { + acc[entry.fieldKey] = { + label: entry.label, + type: entry.type, + value: entry.value, + displayValue: entry.displayValue, + }; + return acc; + }, {}); +} + export const list = query({ args: { viewerId: v.optional(v.id("users")), @@ -199,6 +330,10 @@ export const getById = query({ .withIndex("by_ticket", (q) => q.eq("ticketId", id)) .collect(); + const customFieldsRecord = mapCustomFieldsToRecord( + (t.customFields as NormalizedCustomField[] | undefined) ?? undefined + ); + const commentsHydrated = await Promise.all( comments.map(async (c) => { const author = (await ctx.db.get(c.authorId)) as Doc<"users"> | null; @@ -290,7 +425,7 @@ export const getById = query({ : null, }, description: undefined, - customFields: {}, + customFields: customFieldsRecord, timeline: timeline.map((ev) => { let payload = ev.payload; if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) { @@ -323,6 +458,14 @@ export const create = mutation({ requesterId: v.id("users"), categoryId: v.id("ticketCategories"), subcategoryId: v.id("ticketSubcategories"), + customFields: v.optional( + v.array( + v.object({ + fieldId: v.id("ticketFields"), + value: v.any(), + }) + ) + ), }, handler: async (ctx, args) => { const { role } = await requireUser(ctx, args.actorId, args.tenantId) @@ -342,6 +485,8 @@ export const create = mutation({ if (!subcategory || subcategory.categoryId !== args.categoryId || subcategory.tenantId !== args.tenantId) { throw new ConvexError("Subcategoria inválida"); } + + const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined); // compute next reference (simple monotonic counter per tenant) const existing = await ctx.db .query("tickets") @@ -374,6 +519,7 @@ export const create = mutation({ tags: [], slaPolicyId: undefined, dueAt: undefined, + customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined, }); const requester = await ctx.db.get(args.requesterId); await ctx.db.insert("ticketEvents", { diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 4db4ccb..4aa7b5c 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -231,6 +231,43 @@ model AuthAccount { @@index([userId]) } +model AuthInvite { + id String @id @default(cuid()) + email String + name String? + role String @default("agent") + tenantId String + token String @unique + status String @default("pending") + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdById String? + acceptedAt DateTime? + acceptedById String? + revokedAt DateTime? + revokedById String? + revokedReason String? + + events AuthInviteEvent[] + + @@index([tenantId, status]) + @@index([tenantId, email]) +} + +model AuthInviteEvent { + id String @id @default(cuid()) + inviteId String + type String + payload Json? + actorId String? + createdAt DateTime @default(now()) + + invite AuthInvite @relation(fields: [inviteId], references: [id], onDelete: Cascade) + + @@index([inviteId, createdAt]) +} + model AuthVerification { id String @id @default(cuid()) identifier String diff --git a/web/src/app/admin/channels/page.tsx b/web/src/app/admin/channels/page.tsx index c2b091f..d2f72d3 100644 --- a/web/src/app/admin/channels/page.tsx +++ b/web/src/app/admin/channels/page.tsx @@ -1,17 +1,22 @@ import { QueuesManager } from "@/components/admin/queues/queues-manager" +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" export const dynamic = "force-dynamic" export default function AdminChannelsPage() { return ( -
-
-

Filas e canais

-

- Configure as filas internas e vincule-as aos times responsáveis por cada canal de atendimento. -

-
- -
+ + } + > +
+ +
+
) } diff --git a/web/src/app/admin/fields/page.tsx b/web/src/app/admin/fields/page.tsx index 8a05d1a..479df3a 100644 --- a/web/src/app/admin/fields/page.tsx +++ b/web/src/app/admin/fields/page.tsx @@ -1,17 +1,24 @@ +import { CategoriesManager } from "@/components/admin/categories/categories-manager" import { FieldsManager } from "@/components/admin/fields/fields-manager" +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" export const dynamic = "force-dynamic" export default function AdminFieldsPage() { return ( -
-
-

Campos personalizados

-

- Defina quais informações adicionais devem ser coletadas nos tickets de cada tenant. -

-
- -
+ + } + > +
+ + +
+
) } diff --git a/web/src/app/admin/page.tsx b/web/src/app/admin/page.tsx index 1f1aa11..0b2ec65 100644 --- a/web/src/app/admin/page.tsx +++ b/web/src/app/admin/page.tsx @@ -1,7 +1,10 @@ import { AdminUsersManager } from "@/components/admin/admin-users-manager" +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" import { ROLE_OPTIONS, normalizeRole } from "@/lib/authz" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { prisma } from "@/lib/prisma" +import { normalizeInvite, type NormalizedInvite } from "@/server/invite-utils" export const runtime = "nodejs" export const dynamic = "force-dynamic" @@ -31,18 +34,46 @@ async function loadUsers() { })) } +async function loadInvites(): Promise { + const invites = await prisma.authInvite.findMany({ + orderBy: { createdAt: "desc" }, + include: { + events: { + orderBy: { createdAt: "asc" }, + }, + }, + }) + + const now = new Date() + return invites.map((invite) => normalizeInvite(invite, now)) +} + export default async function AdminPage() { const users = await loadUsers() + const invites = await loadInvites() + const invitesForClient = invites.map((invite) => { + const { events, ...rest } = invite + void events + return rest + }) return ( -
-
-

Administração

-

- Convide novos membros, ajuste papéis e organize as filas e categorias de atendimento. -

+ + } + > +
+
- -
+ ) } diff --git a/web/src/app/admin/slas/page.tsx b/web/src/app/admin/slas/page.tsx index 78c4a77..abe0902 100644 --- a/web/src/app/admin/slas/page.tsx +++ b/web/src/app/admin/slas/page.tsx @@ -1,17 +1,22 @@ import { SlasManager } from "@/components/admin/slas/slas-manager" +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" export const dynamic = "force-dynamic" export default function AdminSlasPage() { return ( -
-
-

Políticas de SLA

-

- Configure tempos de resposta e resolução para garantir a cobertura dos acordos de serviço. -

-
- -
+ + } + > +
+ +
+
) } diff --git a/web/src/app/admin/teams/page.tsx b/web/src/app/admin/teams/page.tsx index 2893f8a..89672ad 100644 --- a/web/src/app/admin/teams/page.tsx +++ b/web/src/app/admin/teams/page.tsx @@ -1,17 +1,22 @@ import { TeamsManager } from "@/components/admin/teams/teams-manager" +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" export const dynamic = "force-dynamic" export default function AdminTeamsPage() { return ( -
-
-

Times e agentes

-

- Estruture squads, capítulos e equipes responsáveis pelos tickets antes de associar filas e SLAs. -

-
- -
+ + } + > +
+ +
+
) } diff --git a/web/src/app/api/admin/invites/[id]/route.ts b/web/src/app/api/admin/invites/[id]/route.ts new file mode 100644 index 0000000..1247d03 --- /dev/null +++ b/web/src/app/api/admin/invites/[id]/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from "next/server" + +import { ConvexHttpClient } from "convex/browser" + +// @ts-expect-error Convex runtime API lacks generated types at build time in Next routes +import { api } from "@/convex/_generated/api" +import { assertAdminSession } from "@/lib/auth-server" +import { env } from "@/lib/env" +import { prisma } from "@/lib/prisma" +import { computeInviteStatus, normalizeInvite, type NormalizedInvite } from "@/server/invite-utils" + +type RevokePayload = { + reason?: string +} + +async function syncInvite(invite: NormalizedInvite) { + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) return + const client = new ConvexHttpClient(convexUrl) + await client.mutation(api.invites.sync, { + tenantId: invite.tenantId, + inviteId: invite.id, + email: invite.email, + name: invite.name ?? undefined, + role: invite.role.toUpperCase(), + status: invite.status, + token: invite.token, + expiresAt: Date.parse(invite.expiresAt), + createdAt: Date.parse(invite.createdAt), + createdById: invite.createdById ?? undefined, + acceptedAt: invite.acceptedAt ? Date.parse(invite.acceptedAt) : undefined, + acceptedById: invite.acceptedById ?? undefined, + revokedAt: invite.revokedAt ? Date.parse(invite.revokedAt) : undefined, + revokedById: invite.revokedById ?? undefined, + revokedReason: invite.revokedReason ?? undefined, + }) +} + +export async function PATCH(request: Request, { params }: { params: { id: string } }) { + const session = await assertAdminSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const body = (await request.json().catch(() => null)) as Partial | null + const reason = typeof body?.reason === "string" && body.reason.trim() ? body.reason.trim() : null + + const invite = await prisma.authInvite.findUnique({ + where: { id: params.id }, + include: { events: { orderBy: { createdAt: "asc" } } }, + }) + + if (!invite) { + return NextResponse.json({ error: "Convite não encontrado" }, { status: 404 }) + } + + const now = new Date() + const status = computeInviteStatus(invite, now) + + if (status === "accepted") { + return NextResponse.json({ error: "Convite já aceito" }, { status: 400 }) + } + + if (status === "revoked") { + const normalized = normalizeInvite(invite, now) + await syncInvite(normalized) + return NextResponse.json({ invite: normalized }) + } + + const updated = await prisma.authInvite.update({ + where: { id: invite.id }, + data: { + status: "revoked", + revokedAt: now, + revokedById: session.user.id ?? null, + revokedReason: reason, + }, + }) + + const event = await prisma.authInviteEvent.create({ + data: { + inviteId: invite.id, + type: "revoked", + payload: reason ? { reason } : null, + actorId: session.user.id ?? null, + }, + }) + + const normalized = normalizeInvite({ ...updated, events: [...invite.events, event] }, now) + await syncInvite(normalized) + + return NextResponse.json({ invite: normalized }) +} diff --git a/web/src/app/api/admin/invites/route.ts b/web/src/app/api/admin/invites/route.ts new file mode 100644 index 0000000..f1d2db8 --- /dev/null +++ b/web/src/app/api/admin/invites/route.ts @@ -0,0 +1,205 @@ +import { NextResponse } from "next/server" +import { randomBytes } from "crypto" + +import { ConvexHttpClient } from "convex/browser" + +// @ts-expect-error Convex runtime API lacks generated types at build time in Next routes +import { api } from "@/convex/_generated/api" +import { assertAdminSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz" +import { env } from "@/lib/env" +import { prisma } from "@/lib/prisma" +import { computeInviteStatus, normalizeInvite, type InviteWithEvents, type NormalizedInvite } from "@/server/invite-utils" + +const DEFAULT_EXPIRATION_DAYS = 7 + +function normalizeRole(input: string | null | undefined): RoleOption { + const role = (input ?? "agent").toLowerCase() as RoleOption + return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent" +} + +function generateToken() { + return randomBytes(32).toString("hex") +} + +async function syncInviteWithConvex(invite: NormalizedInvite) { + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) return + + const client = new ConvexHttpClient(convexUrl) + await client.mutation(api.invites.sync, { + tenantId: invite.tenantId, + inviteId: invite.id, + email: invite.email, + name: invite.name ?? undefined, + role: invite.role.toUpperCase(), + status: invite.status, + token: invite.token, + expiresAt: Date.parse(invite.expiresAt), + createdAt: Date.parse(invite.createdAt), + createdById: invite.createdById ?? undefined, + acceptedAt: invite.acceptedAt ? Date.parse(invite.acceptedAt) : undefined, + acceptedById: invite.acceptedById ?? undefined, + revokedAt: invite.revokedAt ? Date.parse(invite.revokedAt) : undefined, + revokedById: invite.revokedById ?? undefined, + revokedReason: invite.revokedReason ?? undefined, + }) +} + +async function appendEvent(inviteId: string, type: string, actorId: string | null, payload: unknown = null) { + return prisma.authInviteEvent.create({ + data: { + inviteId, + type, + payload, + actorId, + }, + }) +} + +async function refreshInviteStatus(invite: InviteWithEvents, now: Date) { + const computedStatus = computeInviteStatus(invite, now) + if (computedStatus === invite.status) { + return invite + } + + const updated = await prisma.authInvite.update({ + where: { id: invite.id }, + data: { status: computedStatus }, + }) + + const event = await appendEvent(invite.id, computedStatus, null) + + const inviteWithEvents: InviteWithEvents = { + ...updated, + events: [...invite.events, event], + } + return inviteWithEvents +} + +function buildInvitePayload(invite: InviteWithEvents, now: Date) { + const normalized = normalizeInvite(invite, now) + return normalized +} + +export async function GET() { + const session = await assertAdminSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const invites = await prisma.authInvite.findMany({ + orderBy: { createdAt: "desc" }, + include: { + events: { + orderBy: { createdAt: "asc" }, + }, + }, + }) + + const now = new Date() + const results: NormalizedInvite[] = [] + + for (const invite of invites) { + const updatedInvite = await refreshInviteStatus(invite, now) + const normalized = buildInvitePayload(updatedInvite, now) + await syncInviteWithConvex(normalized) + results.push(normalized) + } + + return NextResponse.json({ invites: results }) +} + +type CreateInvitePayload = { + email: string + name?: string + role?: RoleOption + tenantId?: string + expiresInDays?: number +} + +export async function POST(request: Request) { + const session = await assertAdminSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const body = (await request.json().catch(() => null)) as Partial | null + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "Payload inválido" }, { status: 400 }) + } + + const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "" + if (!email || !email.includes("@")) { + return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 }) + } + + const name = typeof body.name === "string" ? body.name.trim() : undefined + const role = normalizeRole(body.role) + const tenantId = typeof body.tenantId === "string" && body.tenantId.trim() ? body.tenantId.trim() : session.user.tenantId || DEFAULT_TENANT_ID + const expiresInDays = Number.isFinite(body.expiresInDays) ? Math.max(1, Number(body.expiresInDays)) : DEFAULT_EXPIRATION_DAYS + + const existing = await prisma.authInvite.findFirst({ + where: { + email, + status: { in: ["pending", "accepted"] }, + }, + include: { events: true }, + }) + + const now = new Date() + if (existing) { + const computed = computeInviteStatus(existing, now) + if (computed === "pending") { + return NextResponse.json({ error: "Já existe um convite pendente para este e-mail" }, { status: 409 }) + } + if (computed === "accepted") { + return NextResponse.json({ error: "Este e-mail já possui acesso ativo" }, { status: 409 }) + } + if (existing.status !== computed) { + await prisma.authInvite.update({ where: { id: existing.id }, data: { status: computed } }) + await appendEvent(existing.id, computed, session.user.id ?? null) + const refreshed = await prisma.authInvite.findUnique({ + where: { id: existing.id }, + include: { events: true }, + }) + if (refreshed) { + const normalizedExisting = buildInvitePayload(refreshed, now) + await syncInviteWithConvex(normalizedExisting) + } + } + } + + const token = generateToken() + const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) + + const invite = await prisma.authInvite.create({ + data: { + email, + name, + role, + tenantId, + token, + status: "pending", + expiresAt, + createdById: session.user.id ?? null, + }, + }) + + const event = await appendEvent(invite.id, "created", session.user.id ?? null, { + role, + tenantId, + expiresAt: expiresAt.toISOString(), + }) + + const inviteWithEvents: InviteWithEvents = { + ...invite, + events: [event], + } + + const normalized = buildInvitePayload(inviteWithEvents, now) + await syncInviteWithConvex(normalized) + + return NextResponse.json({ invite: normalized }) +} diff --git a/web/src/app/api/invites/[token]/route.ts b/web/src/app/api/invites/[token]/route.ts new file mode 100644 index 0000000..0a4d46e --- /dev/null +++ b/web/src/app/api/invites/[token]/route.ts @@ -0,0 +1,196 @@ +import { NextResponse } from "next/server" + +import { hashPassword } from "better-auth/crypto" +import { ConvexHttpClient } from "convex/browser" + +// @ts-expect-error Convex generated API lacks types in Next routes +import { api } from "@/convex/_generated/api" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { env } from "@/lib/env" +import { prisma } from "@/lib/prisma" +import { + computeInviteStatus, + normalizeInvite, + normalizeRoleOption, + type NormalizedInvite, +} from "@/server/invite-utils" + +type AcceptInvitePayload = { + name?: string + password: string +} + +function validatePassword(password: string) { + return password.length >= 8 +} + +async function syncInvite(invite: NormalizedInvite) { + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) return + const client = new ConvexHttpClient(convexUrl) + await client.mutation(api.invites.sync, { + tenantId: invite.tenantId, + inviteId: invite.id, + email: invite.email, + name: invite.name ?? undefined, + role: invite.role.toUpperCase(), + status: invite.status, + token: invite.token, + expiresAt: Date.parse(invite.expiresAt), + createdAt: Date.parse(invite.createdAt), + createdById: invite.createdById ?? undefined, + acceptedAt: invite.acceptedAt ? Date.parse(invite.acceptedAt) : undefined, + acceptedById: invite.acceptedById ?? undefined, + revokedAt: invite.revokedAt ? Date.parse(invite.revokedAt) : undefined, + revokedById: invite.revokedById ?? undefined, + revokedReason: invite.revokedReason ?? undefined, + }) +} + +export async function GET(_request: Request, { params }: { params: { token: string } }) { + const invite = await prisma.authInvite.findUnique({ + where: { token: params.token }, + include: { events: { orderBy: { createdAt: "asc" } } }, + }) + + if (!invite) { + return NextResponse.json({ error: "Convite não encontrado" }, { status: 404 }) + } + + const now = new Date() + const status = computeInviteStatus(invite, now) + + if (status !== invite.status) { + await prisma.authInvite.update({ where: { id: invite.id }, data: { status } }) + const event = await prisma.authInviteEvent.create({ + data: { + inviteId: invite.id, + type: status, + payload: null, + actorId: null, + }, + }) + invite.status = status + invite.events.push(event) + } + + const normalized = normalizeInvite(invite, now) + await syncInvite(normalized) + + return NextResponse.json({ invite: normalized }) +} + +export async function POST(request: Request, { params }: { params: { token: string } }) { + const payload = (await request.json().catch(() => null)) as Partial | null + if (!payload || typeof payload.password !== "string") { + return NextResponse.json({ error: "Senha inválida" }, { status: 400 }) + } + + if (!validatePassword(payload.password)) { + return NextResponse.json({ error: "Senha deve conter pelo menos 8 caracteres" }, { status: 400 }) + } + + const invite = await prisma.authInvite.findUnique({ + where: { token: params.token }, + include: { events: { orderBy: { createdAt: "asc" } } }, + }) + + if (!invite) { + return NextResponse.json({ error: "Convite não encontrado" }, { status: 404 }) + } + + const now = new Date() + const status = computeInviteStatus(invite, now) + + if (status === "expired") { + await prisma.authInvite.update({ where: { id: invite.id }, data: { status: "expired" } }) + const event = await prisma.authInviteEvent.create({ + data: { + inviteId: invite.id, + type: "expired", + payload: null, + actorId: null, + }, + }) + invite.status = "expired" + invite.events.push(event) + const normalizedExpired = normalizeInvite(invite, now) + await syncInvite(normalizedExpired) + return NextResponse.json({ error: "Convite expirado" }, { status: 410 }) + } + + if (status === "revoked") { + return NextResponse.json({ error: "Convite revogado" }, { status: 410 }) + } + + if (status === "accepted") { + return NextResponse.json({ error: "Convite já utilizado" }, { status: 409 }) + } + + const existingUser = await prisma.authUser.findUnique({ where: { email: invite.email } }) + if (existingUser) { + return NextResponse.json({ error: "Usuário já registrado" }, { status: 409 }) + } + + const name = typeof payload.name === "string" && payload.name.trim() ? payload.name.trim() : invite.name || invite.email + const tenantId = invite.tenantId || DEFAULT_TENANT_ID + const role = normalizeRoleOption(invite.role) + + const hashedPassword = await hashPassword(payload.password) + + const user = await prisma.authUser.create({ + data: { + email: invite.email, + name, + role, + tenantId, + accounts: { + create: { + providerId: "credential", + accountId: invite.email, + password: hashedPassword, + }, + }, + }, + }) + + const updatedInvite = await prisma.authInvite.update({ + where: { id: invite.id }, + data: { + status: "accepted", + acceptedAt: now, + acceptedById: user.id, + name, + }, + }) + + const event = await prisma.authInviteEvent.create({ + data: { + inviteId: invite.id, + type: "accepted", + payload: { userId: user.id }, + actorId: user.id, + }, + }) + + const normalized = normalizeInvite({ ...updatedInvite, events: [...invite.events, event] }, now) + await syncInvite(normalized) + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (convexUrl) { + try { + const convex = new ConvexHttpClient(convexUrl) + await convex.mutation(api.users.ensureUser, { + tenantId, + email: invite.email, + name, + avatarUrl: undefined, + role: role.toUpperCase(), + }) + } catch (error) { + console.warn("Falha ao sincronizar usuário no Convex", error) + } + } + + return NextResponse.json({ success: true }) +} diff --git a/web/src/app/invite/[token]/page.tsx b/web/src/app/invite/[token]/page.tsx new file mode 100644 index 0000000..cb33c0f --- /dev/null +++ b/web/src/app/invite/[token]/page.tsx @@ -0,0 +1,40 @@ +import { notFound } from "next/navigation" + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { prisma } from "@/lib/prisma" +import { normalizeInvite } from "@/server/invite-utils" +import { InviteAcceptForm } from "@/components/invite/invite-accept-form" + +export const dynamic = "force-dynamic" + +export default async function InvitePage({ params }: { params: { token: string } }) { + const invite = await prisma.authInvite.findUnique({ + where: { token: params.token }, + include: { events: { orderBy: { createdAt: "asc" } } }, + }) + + if (!invite) { + notFound() + } + + const normalized = normalizeInvite(invite, new Date()) + const { events: unusedEvents, inviteUrl: unusedInviteUrl, ...summary } = normalized + void unusedEvents + void unusedInviteUrl + + return ( +
+ + + Aceitar convite + + Conclua seu cadastro para acessar a plataforma Sistema de chamados. + + + + + + +
+ ) +} diff --git a/web/src/app/tickets/new/page.tsx b/web/src/app/tickets/new/page.tsx index 08b5cec..334d7c3 100644 --- a/web/src/app/tickets/new/page.tsx +++ b/web/src/app/tickets/new/page.tsx @@ -30,7 +30,13 @@ import { CategorySelectFields } from "@/components/tickets/category-select" export default function NewTicketPage() { const router = useRouter() const { convexUserId } = useAuth() - const queuesRaw = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined + const queueArgs = convexUserId + ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } + : "skip" + const queuesRaw = useQuery( + convexUserId ? api.queues.summary : "skip", + queueArgs + ) as TicketQueueSummary[] | undefined const queues = useMemo(() => queuesRaw ?? [], [queuesRaw]) const create = useMutation(api.tickets.create) const addComment = useMutation(api.tickets.addComment) diff --git a/web/src/components/admin/admin-users-manager.tsx b/web/src/components/admin/admin-users-manager.tsx index 778125f..fe4a2ba 100644 --- a/web/src/components/admin/admin-users-manager.tsx +++ b/web/src/components/admin/admin-users-manager.tsx @@ -4,6 +4,7 @@ import { useMemo, useState, useTransition } from "react" import { toast } from "sonner" +import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" @@ -28,8 +29,28 @@ type AdminUser = { updatedAt: string | null } +type AdminInvite = { + id: string + email: string + name: string | null + role: RoleOption + tenantId: string + status: "pending" | "accepted" | "revoked" | "expired" + token: string + inviteUrl: string + expiresAt: string + createdAt: string + createdById: string | null + acceptedAt: string | null + acceptedById: string | null + revokedAt: string | null + revokedById: string | null + revokedReason: string | null +} + type Props = { initialUsers: AdminUser[] + initialInvites: AdminInvite[] roleOptions: readonly RoleOption[] defaultTenantId: string } @@ -42,29 +63,59 @@ function formatDate(dateIso: string) { }).format(date) } -export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId }: Props) { - const [users, setUsers] = useState(initialUsers) +function formatStatus(status: AdminInvite["status"]) { + switch (status) { + case "pending": + return "Pendente" + case "accepted": + return "Aceito" + case "revoked": + return "Revogado" + case "expired": + return "Expirado" + default: + return status + } +} + +function sanitizeInvite(invite: AdminInvite & { events?: unknown }): AdminInvite { + const { events: unusedEvents, ...rest } = invite + void unusedEvents + return rest +} + +export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId }: Props) { + const [users] = useState(initialUsers) + const [invites, setInvites] = useState(initialInvites) const [email, setEmail] = useState("") const [name, setName] = useState("") const [role, setRole] = useState("agent") const [tenantId, setTenantId] = useState(defaultTenantId) - const [lastInvite, setLastInvite] = useState<{ email: string; password: string } | null>(null) + const [expiresInDays, setExpiresInDays] = useState("7") + const [lastInviteLink, setLastInviteLink] = useState(null) + const [revokingId, setRevokingId] = useState(null) const [isPending, startTransition] = useTransition() const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions]) - async function handleSubmit(event: React.FormEvent) { + async function handleInviteSubmit(event: React.FormEvent) { event.preventDefault() if (!email || !email.includes("@")) { toast.error("Informe um e-mail válido") return } - const payload = { email, name, role, tenantId } + const payload = { + email, + name, + role, + tenantId, + expiresInDays: Number.parseInt(expiresInDays, 10), + } startTransition(async () => { try { - const response = await fetch("/api/admin/users", { + const response = await fetch("/api/admin/invites", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), @@ -72,44 +123,89 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId } if (!response.ok) { const data = await response.json().catch(() => ({})) - throw new Error(data.error ?? "Não foi possível criar o usuário") + throw new Error(data.error ?? "Não foi possível gerar o convite") } - const data = (await response.json()) as { - user: AdminUser - temporaryPassword: string - } - - setUsers((previous) => [data.user, ...previous]) - setLastInvite({ email: data.user.email, password: data.temporaryPassword }) + const data = (await response.json()) as { invite: AdminInvite } + const nextInvite = sanitizeInvite(data.invite) + setInvites((previous) => [nextInvite, ...previous.filter((item) => item.id !== nextInvite.id)]) setEmail("") setName("") setRole("agent") setTenantId(defaultTenantId) - toast.success("Usuário criado com sucesso") + setExpiresInDays("7") + setLastInviteLink(nextInvite.inviteUrl) + toast.success("Convite criado com sucesso") } catch (error) { - const message = error instanceof Error ? error.message : "Falha ao criar usuário" + const message = error instanceof Error ? error.message : "Falha ao criar convite" toast.error(message) } }) } + function handleCopy(link: string) { + navigator.clipboard + .writeText(link) + .then(() => toast.success("Link de convite copiado")) + .catch(() => toast.error("Não foi possível copiar o link")) + } + + async function handleRevoke(inviteId: string) { + const invite = invites.find((item) => item.id === inviteId) + if (!invite || invite.status !== "pending") { + return + } + + const confirmed = window.confirm("Deseja revogar este convite?") + if (!confirmed) return + + setRevokingId(inviteId) + try { + const response = await fetch(`/api/admin/invites/${inviteId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason: "Revogado manualmente" }), + }) + + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error ?? "Falha ao revogar convite") + } + + const data = (await response.json()) as { invite: AdminInvite } + const updated = sanitizeInvite(data.invite) + setInvites((previous) => previous.map((item) => (item.id === updated.id ? updated : item))) + toast.success("Convite revogado") + } catch (error) { + const message = error instanceof Error ? error.message : "Erro ao revogar convite" + toast.error(message) + } finally { + setRevokingId(null) + } + } + return ( - + + Convites Usuários Filas Categorias - + - Convidar novo usuário - Crie um acesso provisório e compartilhe a senha inicial com o colaborador. + Gerar convite + + Envie convites personalizados com validade controlada e acompanhe o status em tempo real. + -
+
{normalizedRoles.map((item) => ( - {item === "customer" ? "Cliente" : item === "admin" ? "Administrador" : item} + {item === "customer" + ? "Cliente" + : item === "admin" + ? "Administrador" + : item === "manager" + ? "Gestor" + : item === "agent" + ? "Agente" + : "Colaborador"} ))} @@ -156,29 +260,115 @@ export function AdminUsersManager({ initialUsers, roleOptions, defaultTenantId } onChange={(event) => setTenantId(event.target.value)} />
+
+ + +
- {lastInvite ? ( -
-

Acesso provisório gerado

-

- Envie para {lastInvite.email} a senha inicial - {lastInvite.password}. - Solicite que altere após o primeiro login. -

+ {lastInviteLink ? ( +
+
+

Link de convite pronto

+

Compartilhe com o convidado. O link expira automaticamente no prazo definido.

+

{lastInviteLink}

+
+
) : null} + + + Convites emitidos + Histórico e status atual de todos os convites enviados para o workspace. + + + + + + + + + + + + + + + {invites.map((invite) => ( + + + + + + + + + ))} + {invites.length === 0 ? ( + + + + ) : null} + +
ColaboradorPapelTenantExpira emStatusAções
+
+ {invite.name || invite.email} + {invite.email} +
+
{invite.role}{invite.tenantId}{formatDate(invite.expiresAt)} + + {formatStatus(invite.status)} + + +
+ + {invite.status === "pending" ? ( + + ) : null} +
+
+ Nenhum convite emitido até o momento. +
+
+
+ + + Equipe cadastrada - Lista completa de usuários autenticáveis pela Better Auth. + Usuários ativos e provisionados via convites aceitos. diff --git a/web/src/components/admin/categories/categories-manager.tsx b/web/src/components/admin/categories/categories-manager.tsx new file mode 100644 index 0000000..1c0de46 --- /dev/null +++ b/web/src/components/admin/categories/categories-manager.tsx @@ -0,0 +1,555 @@ +"use client" + +import { useMemo, useState } from "react" +import { useMutation, useQuery } from "convex/react" +import { toast } from "sonner" +// @ts-expect-error Convex runtime API lacks generated types +import { api } from "@/convex/_generated/api" + +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { useAuth } from "@/lib/auth-client" +import type { TicketCategory, TicketSubcategory } from "@/lib/schemas/category" +import type { Id } from "@/convex/_generated/dataModel" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" + +type DeleteState = + | { type: T; targetId: string; reason: string } + | null + +export function CategoriesManager() { + const { session, convexUserId } = useAuth() + const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID + const categories = useQuery(api.categories.list, { tenantId }) as TicketCategory[] | undefined + const [categoryName, setCategoryName] = useState("") + const [categoryDescription, setCategoryDescription] = useState("") + const [subcategoryDraft, setSubcategoryDraft] = useState("") + const [subcategoryList, setSubcategoryList] = useState([]) + const [deleteState, setDeleteState] = useState>(null) + const createCategory = useMutation(api.categories.createCategory) + const deleteCategory = useMutation(api.categories.deleteCategory) + const updateCategory = useMutation(api.categories.updateCategory) + const createSubcategory = useMutation(api.categories.createSubcategory) + const updateSubcategory = useMutation(api.categories.updateSubcategory) + const deleteSubcategory = useMutation(api.categories.deleteSubcategory) + + const isCreatingCategory = useMemo( + () => categoryName.trim().length < 2, + [categoryName] + ) + + function addSubcategory() { + const value = subcategoryDraft.trim() + if (value.length < 2) { + toast.error("Informe um nome válido para a subcategoria") + return + } + const normalized = value.toLowerCase() + const exists = subcategoryList.some((item) => item.toLowerCase() === normalized) + if (exists) { + toast.error("Essa subcategoria já foi adicionada") + return + } + setSubcategoryList((items) => [...items, value]) + setSubcategoryDraft("") + } + + function removeSubcategory(target: string) { + setSubcategoryList((items) => items.filter((item) => item !== target)) + } + + async function handleCreateCategory() { + if (!convexUserId) return + const name = categoryName.trim() + if (name.length < 2) { + toast.error("Informe um nome válido para a categoria") + return + } + toast.loading("Criando categoria...", { id: "category:create" }) + try { + await createCategory({ + tenantId, + actorId: convexUserId as Id<"users">, + name, + description: categoryDescription.trim() || undefined, + secondary: subcategoryList, + }) + toast.success("Categoria criada!", { id: "category:create" }) + setCategoryName("") + setCategoryDescription("") + setSubcategoryDraft("") + setSubcategoryList([]) + } catch (error) { + console.error(error) + toast.error("Não foi possível criar a categoria", { id: "category:create" }) + } + } + + async function handleDeleteCategory(id: string) { + if (!convexUserId) return + toast.loading("Removendo categoria...", { id: "category:delete" }) + try { + await deleteCategory({ + tenantId, + actorId: convexUserId as Id<"users">, + categoryId: id as Id<"ticketCategories">, + }) + toast.success("Categoria removida", { id: "category:delete" }) + } catch (error) { + console.error(error) + toast.error("Não foi possível remover a categoria", { id: "category:delete" }) + } + } + + async function handleUpdateCategory(target: TicketCategory, next: { name: string; description: string }) { + if (!convexUserId) return + const name = next.name.trim() + if (name.length < 2) { + toast.error("Informe um nome válido") + return + } + toast.loading("Atualizando categoria...", { id: `category:update:${target.id}` }) + try { + await updateCategory({ + tenantId, + actorId: convexUserId as Id<"users">, + categoryId: target.id as Id<"ticketCategories">, + name, + description: next.description.trim() || undefined, + }) + toast.success("Categoria atualizada", { id: `category:update:${target.id}` }) + } catch (error) { + console.error(error) + toast.error("Não foi possível atualizar a categoria", { id: `category:update:${target.id}` }) + } + } + + async function handleCreateSubcategory(categoryId: string, payload: { name: string }) { + if (!convexUserId) return + const name = payload.name.trim() + if (name.length < 2) { + toast.error("Informe um nome válido para a subcategoria") + return + } + toast.loading("Adicionando subcategoria...", { id: `subcategory:create:${categoryId}` }) + try { + await createSubcategory({ + tenantId, + actorId: convexUserId as Id<"users">, + categoryId: categoryId as Id<"ticketCategories">, + name, + }) + toast.success("Subcategoria criada", { id: `subcategory:create:${categoryId}` }) + } catch (error) { + console.error(error) + toast.error("Não foi possível criar a subcategoria", { id: `subcategory:create:${categoryId}` }) + } + } + + async function handleUpdateSubcategory(target: TicketSubcategory, name: string) { + if (!convexUserId) return + const trimmed = name.trim() + if (trimmed.length < 2) { + toast.error("Informe um nome válido") + return + } + toast.loading("Atualizando subcategoria...", { id: `subcategory:update:${target.id}` }) + try { + await updateSubcategory({ + tenantId, + actorId: convexUserId as Id<"users">, + subcategoryId: target.id as Id<"ticketSubcategories">, + name: trimmed, + }) + toast.success("Subcategoria atualizada", { id: `subcategory:update:${target.id}` }) + } catch (error) { + console.error(error) + toast.error("Não foi possível atualizar a subcategoria", { id: `subcategory:update:${target.id}` }) + } + } + + async function handleDeleteSubcategory(id: string) { + if (!convexUserId) return + toast.loading("Removendo subcategoria...", { id: `subcategory:delete:${id}` }) + try { + await deleteSubcategory({ + tenantId, + actorId: convexUserId as Id<"users">, + subcategoryId: id as Id<"ticketSubcategories">, + }) + toast.success("Subcategoria removida", { id: `subcategory:delete:${id}` }) + } catch (error) { + console.error(error) + toast.error("Não foi possível remover a subcategoria", { id: `subcategory:delete:${id}` }) + } + } + + const pendingDelete = deleteState + const isDisabled = !convexUserId + + return ( +
+
+
+

Categorias

+

+ Organize a classificação primária e secundária utilizada nos tickets. Todas as alterações entram em vigor + imediatamente para novos atendimentos. +

+
+
+
+
+ + setCategoryName(event.target.value)} + placeholder="Ex.: Incidentes" + disabled={isDisabled} + /> +
+
+ +