diff --git a/agents.md b/agents.md index 5bed7d3..81c27dd 100644 --- a/agents.md +++ b/agents.md @@ -1,3 +1,59 @@ +# Plano de Desenvolvimento — Sistema de Chamados + +## Contato principal +- **Esdras Renan** — monkeyesdras@gmail.com + +## Ambiente local +- Admin: `admin@sistema.dev` / `admin123` +- Agentes seed (senha inicial `agent123` — alterar no primeiro acesso): + - Gabriel Oliveira · george.araujo@rever.com.br + - George Araujo · george.araujo@rever.com.br + - Hugo Soares · hugo.soares@rever.com.br + - Julio Cesar · julio@rever.com.br + - Lorena Magalhães · lorena@rever.com.br + - Rever · renan.pac@paulicon.com.br + - Telão · suporte@rever.com.br + - Thiago Medeiros · thiago.medeiros@rever.com.br + - Weslei Magalhães · weslei@rever.com.br + +> Todos os usuários estão sincronizados com o Convex via `scripts/seed-agents.mjs`. + +## Visão geral atual +- **Meta imediata:** consolidar o núcleo de tickets web/desktop com canais, SLAs e automações futuras. +- **Stack:** Next.js (App Router) + Convex + Better Auth + Prisma (referência de domínio). +- **Estado:** núcleo web funcional (tickets, play mode, painéis administrativos, portal do cliente) com Turbopack habilitado no `pnpm dev`. + +## Entregas concluídas +- Scaffold Next.js + Tailwind + shadcn/ui, shell com sidebar/header, login real com Better Auth. +- Integração Convex completa: listas/detalhe de tickets, mutations (status, categorias, filas, comentários, play next). +- Painel administrativo: gestão de filas, times, campos personalizados e convites Better Auth. +- Portal do cliente isolado por `viewerId`; dashboard principal consumindo métricas reais do Convex. +- Fluxo de convites Better Auth ponta a ponta + seed automatizado de agentes/admin. + +## Desenvolvimento em curso +- Refinar sincronização Better Auth ↔ Convex (resets de senha, revogação automática de convites). +- Melhorar UX do ticket header (categorias, status, prioridades) e comandos rápidos na listagem. +- Manter hidratação consistente na sidebar e componentes Radix após migração para React 19. + +## Próximas prioridades +1. Expandir suíte de testes (UI + Convex) e habilitar pipeline CI obrigatória (lint + vitest). +2. Implementar resets de senha automatizados e auditoria de convites para onboarding/offboarding. +3. Expor categorias/subcategorias dinâmicas na criação/edição de tickets (web e desktop). +4. Adicionar ações avançadas para agentes (edição de categorias, reassignment rápido) sob RBAC. + +## Boas práticas e rotinas +- **Seeds:** `node --env-file=.env.local scripts/seed-agents.mjs` (mantém admin e agentes) + `/dev/seed` para dados demo. +- **Serviços locais:** `pnpm convex:dev` (gera tipos e roda backend) e `pnpm dev` (Next.js com Turbopack). +- **Testes e lint:** execute `pnpm lint` e `pnpm vitest run` antes de cada PR. +- **Convex:** retorne apenas tipos suportados (`number` para datas) e valide no front via mappers Zod. +- **UI:** textos PT‑BR, toasts com feedback, atualizações otimistas com rollback em caso de erro. +- **Git/PR:** branches descritivas, checklist padrão (tipos Convex, labels PT‑BR, loaders, mappers atualizados) e coautor `factory-droid[bot]` quando aplicável. + +## Histórico de marcos +- Fase A (scaffold/UX base) e Fase B (núcleo de tickets) concluídas. +- Iniciativa “Autenticação real e personas” entregue com RBAC completo e portal do cliente. +- Roadmap imediato focado em credenciais unificadas, automações de convites e cobertura de testes. + # Plano de Desenvolvimento - Sistema de Chamados ## Meta imediata diff --git a/web/convex/tickets.ts b/web/convex/tickets.ts index 6d9da05..6d0dc03 100644 --- a/web/convex/tickets.ts +++ b/web/convex/tickets.ts @@ -5,6 +5,8 @@ import { Id, type Doc } from "./_generated/dataModel"; import { requireCustomer, requireStaff, requireUser } from "./rbac"; +const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]); + const QUEUE_RENAME_LOOKUP: Record = { "Suporte N1": "Chamados", "suporte-n1": "Chamados", @@ -459,6 +461,7 @@ export const create = mutation({ channel: v.string(), queueId: v.optional(v.id("queues")), requesterId: v.id("users"), + assigneeId: v.optional(v.id("users")), categoryId: v.id("ticketCategories"), subcategoryId: v.id("ticketSubcategories"), customFields: v.optional( @@ -471,11 +474,34 @@ export const create = mutation({ ), }, handler: async (ctx, args) => { - const { role } = await requireUser(ctx, args.actorId, args.tenantId) + const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId) if (role === "CUSTOMER" && args.requesterId !== args.actorId) { throw new ConvexError("Clientes só podem abrir chamados para si mesmos") } + if (args.assigneeId && (!role || !STAFF_ROLES.has(role))) { + throw new ConvexError("Somente a equipe interna pode definir o responsável") + } + + let initialAssigneeId: Id<"users"> | undefined + let initialAssignee: Doc<"users"> | null = null + + if (args.assigneeId) { + const assignee = (await ctx.db.get(args.assigneeId)) as Doc<"users"> | null + if (!assignee || assignee.tenantId !== args.tenantId) { + throw new ConvexError("Responsável inválido") + } + const normalizedAssigneeRole = (assignee.role ?? "AGENT").toUpperCase() + if (!STAFF_ROLES.has(normalizedAssigneeRole)) { + throw new ConvexError("Responsável inválido") + } + initialAssigneeId = assignee._id + initialAssignee = assignee + } else if (role && STAFF_ROLES.has(role)) { + initialAssigneeId = actorUser._id + initialAssignee = actorUser + } + const subject = args.subject.trim(); if (subject.length < 3) { throw new ConvexError("Informe um assunto com pelo menos 3 caracteres"); @@ -510,7 +536,7 @@ export const create = mutation({ categoryId: args.categoryId, subcategoryId: args.subcategoryId, requesterId: args.requesterId, - assigneeId: undefined, + assigneeId: initialAssigneeId, working: false, activeSessionId: undefined, totalWorkedMs: 0, @@ -531,6 +557,16 @@ export const create = mutation({ payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl }, createdAt: now, }); + + if (initialAssigneeId && initialAssignee) { + await ctx.db.insert("ticketEvents", { + ticketId: id, + type: "ASSIGNEE_CHANGED", + payload: { assigneeId: initialAssigneeId, assigneeName: initialAssignee.name, actorId: args.actorId }, + createdAt: now, + }) + } + return id; }, }); @@ -558,10 +594,23 @@ export const addComment = mutation({ throw new ConvexError("Ticket não encontrado") } + const author = (await ctx.db.get(args.authorId)) as Doc<"users"> | null + if (!author || author.tenantId !== ticket.tenantId) { + throw new ConvexError("Autor do comentário inválido") + } + + const normalizedRole = (author.role ?? "AGENT").toUpperCase() + if (ticket.requesterId === args.authorId) { - await requireCustomer(ctx, args.authorId, ticket.tenantId) - if (args.visibility !== "PUBLIC") { - throw new ConvexError("Clientes só podem registrar comentários públicos") + if (normalizedRole === "CUSTOMER") { + await requireCustomer(ctx, args.authorId, ticket.tenantId) + if (args.visibility !== "PUBLIC") { + throw new ConvexError("Clientes só podem registrar comentários públicos") + } + } else if (STAFF_ROLES.has(normalizedRole)) { + await requireStaff(ctx, args.authorId, ticket.tenantId) + } else { + throw new ConvexError("Autor não possui permissão para comentar") } } else { await requireStaff(ctx, args.authorId, ticket.tenantId) @@ -577,11 +626,10 @@ export const addComment = mutation({ createdAt: now, updatedAt: now, }); - const author = await ctx.db.get(args.authorId); await ctx.db.insert("ticketEvents", { ticketId: args.ticketId, type: "COMMENT_ADDED", - payload: { authorId: args.authorId, authorName: author?.name, authorAvatar: author?.avatarUrl }, + payload: { authorId: args.authorId, authorName: author.name, authorAvatar: author.avatarUrl }, createdAt: now, }); // bump ticket updatedAt diff --git a/web/convex/users.ts b/web/convex/users.ts index a739fb8..7dadbd8 100644 --- a/web/convex/users.ts +++ b/web/convex/users.ts @@ -1,5 +1,8 @@ import { mutation, query } from "./_generated/server"; -import { v } from "convex/values"; +import { ConvexError, v } from "convex/values"; +import { requireAdmin } from "./rbac"; + +const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]); export const ensureUser = mutation({ args: { @@ -69,11 +72,41 @@ export const ensureUser = mutation({ export const listAgents = query({ args: { tenantId: v.string() }, handler: async (ctx, { tenantId }) => { - const agents = await ctx.db + const users = await ctx.db .query("users") - .withIndex("by_tenant_role", (q) => q.eq("tenantId", tenantId).eq("role", "AGENT")) + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); - return agents; + + return users + .filter((user) => { + const normalizedRole = (user.role ?? "AGENT").toUpperCase(); + return STAFF_ROLES.has(normalizedRole); + }) + .sort((a, b) => a.name.localeCompare(b.name, "pt-BR")); + }, +}); + +export const deleteUser = mutation({ + args: { userId: v.id("users"), actorId: v.id("users") }, + handler: async (ctx, { userId, actorId }) => { + const user = await ctx.db.get(userId); + if (!user) { + return { status: "not_found" }; + } + + await requireAdmin(ctx, actorId, user.tenantId); + + const assignedTickets = await ctx.db + .query("tickets") + .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", user.tenantId).eq("assigneeId", userId)) + .take(1); + + if (assignedTickets.length > 0) { + throw new ConvexError("Usuário ainda está atribuído a tickets"); + } + + await ctx.db.delete(userId); + return { status: "deleted" }; }, }); diff --git a/web/public/rever-8.png b/web/public/rever-8.png new file mode 100644 index 0000000..1b72521 Binary files /dev/null and b/web/public/rever-8.png differ diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index f8a79ae..3e7afe5 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -20,6 +20,9 @@ const jetBrainsMono = JetBrains_Mono({ export const metadata: Metadata = { title: "Sistema de chamados", description: "Plataforma de chamados da Rever", + icons: { + icon: "/rever-8.png", + }, } export default async function RootLayout({ diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 3dbb6c8..b21ded6 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -1,6 +1,7 @@ "use client" import { useEffect, useState } from "react" +import Image from "next/image" import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" import { GalleryVerticalEnd } from "lucide-react" @@ -22,10 +23,11 @@ export default function LoginPage() { const [isHydrated, setIsHydrated] = useState(false) useEffect(() => { + if (isPending) return if (!session?.user) return const destination = callbackUrl ?? "/dashboard" router.replace(destination) - }, [callbackUrl, router, session?.user]) + }, [callbackUrl, isPending, router, session?.user]) useEffect(() => { setIsHydrated(true) @@ -36,12 +38,12 @@ export default function LoginPage() { return (
-
- +
+
- Sistema de Chamados + Sistema de chamados
@@ -49,6 +51,19 @@ export default function LoginPage() {
+
+ Logotipo Rever Tecnologia +
+
+ Desenvolvido por Esdras Renan +
diff --git a/web/src/app/reports/backlog/page.tsx b/web/src/app/reports/backlog/page.tsx index a8e1448..77715c5 100644 --- a/web/src/app/reports/backlog/page.tsx +++ b/web/src/app/reports/backlog/page.tsx @@ -1,17 +1,22 @@ +import { AppShell } from "@/components/app-shell" import { BacklogReport } from "@/components/reports/backlog-report" +import { SiteHeader } from "@/components/site-header" export const dynamic = "force-dynamic" export default function ReportsBacklogPage() { return ( -
-
-

Backlog e Prioridades

-

- Avalie o volume de tickets em aberto, prioridades e filas mais pressionadas. -

-
- -
+ + } + > +
+ +
+
) } diff --git a/web/src/app/reports/csat/page.tsx b/web/src/app/reports/csat/page.tsx index c16086a..83d0715 100644 --- a/web/src/app/reports/csat/page.tsx +++ b/web/src/app/reports/csat/page.tsx @@ -1,17 +1,22 @@ +import { AppShell } from "@/components/app-shell" import { CsatReport } from "@/components/reports/csat-report" +import { SiteHeader } from "@/components/site-header" export const dynamic = "force-dynamic" export default function ReportsCsatPage() { return ( -
-
-

Relatório de CSAT

-

- Visualize a satisfação dos clientes e identifique pontos de melhoria na entrega. -

-
- -
+ + } + > +
+ +
+
) } diff --git a/web/src/app/reports/sla/page.tsx b/web/src/app/reports/sla/page.tsx index 32c8341..824f935 100644 --- a/web/src/app/reports/sla/page.tsx +++ b/web/src/app/reports/sla/page.tsx @@ -1,17 +1,22 @@ +import { AppShell } from "@/components/app-shell" import { SlaReport } from "@/components/reports/sla-report" +import { SiteHeader } from "@/components/site-header" export const dynamic = "force-dynamic" export default function ReportsSlaPage() { return ( -
-
-

Relatório de SLA

-

- Acompanhe tempos de resposta, resolução e balanço de filas em tempo real. -

-
- -
+ + } + > +
+ +
+
) } diff --git a/web/src/app/tickets/new/page.tsx b/web/src/app/tickets/new/page.tsx index 334d7c3..a38b4bf 100644 --- a/web/src/app/tickets/new/page.tsx +++ b/web/src/app/tickets/new/page.tsx @@ -1,10 +1,10 @@ "use client" -import { useMemo, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" -import type { Id } from "@/convex/_generated/dataModel" +import type { Doc, Id } from "@/convex/_generated/dataModel" import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { useAuth } from "@/lib/auth-client" @@ -40,12 +40,18 @@ export default function NewTicketPage() { const queues = useMemo(() => queuesRaw ?? [], [queuesRaw]) const create = useMutation(api.tickets.create) const addComment = useMutation(api.tickets.addComment) + const staffRaw = useQuery(api.users.listAgents, { tenantId: DEFAULT_TENANT_ID }) as Doc<"users">[] | undefined + const staff = useMemo( + () => (staffRaw ?? []).sort((a, b) => a.name.localeCompare(b.name, "pt-BR")), + [staffRaw] + ) const [subject, setSubject] = useState("") const [summary, setSummary] = useState("") const [priority, setPriority] = useState("MEDIUM") const [channel, setChannel] = useState("MANUAL") const [queueName, setQueueName] = useState(null) + const [assigneeId, setAssigneeId] = useState(null) const [description, setDescription] = useState("") const [loading, setLoading] = useState(false) const [subjectError, setSubjectError] = useState(null) @@ -53,8 +59,17 @@ export default function NewTicketPage() { const [subcategoryId, setSubcategoryId] = useState(null) const [categoryError, setCategoryError] = useState(null) const [subcategoryError, setSubcategoryError] = useState(null) + const [assigneeInitialized, setAssigneeInitialized] = useState(false) const queueOptions = useMemo(() => queues.map((q) => q.name), [queues]) + const assigneeSelectValue = assigneeId ?? "NONE" + + useEffect(() => { + if (assigneeInitialized) return + if (!convexUserId) return + setAssigneeId(convexUserId) + setAssigneeInitialized(true) + }, [assigneeInitialized, convexUserId]) async function submit(event: React.FormEvent) { event.preventDefault() @@ -81,6 +96,7 @@ export default function NewTicketPage() { try { const selQueue = queues.find((q) => q.name === queueName) const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined + const assigneeToSend = assigneeId ? (assigneeId as Id<"users">) : undefined const id = await create({ actorId: convexUserId as Id<"users">, tenantId: DEFAULT_TENANT_ID, @@ -90,6 +106,7 @@ export default function NewTicketPage() { channel, queueId, requesterId: convexUserId as Id<"users">, + assigneeId: assigneeToSend, categoryId: categoryId as Id<"ticketCategories">, subcategoryId: subcategoryId as Id<"ticketSubcategories">, }) @@ -150,6 +167,7 @@ export default function NewTicketPage() { className="min-h-[96px] w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-neutral-800 shadow-sm outline-none transition-colors focus-visible:border-[#00d6eb] focus-visible:ring-[3px] focus-visible:ring-[#00e8ff]/20" value={summary} onChange={(event) => setSummary(event.target.value)} + placeholder="Resuma rapidamente o cenário ou impacto do ticket." />
@@ -177,7 +195,7 @@ export default function NewTicketPage() {
) : null}
-
+
Prioridade
+
+ Responsável + +