Merge pull request #20 from esdrasrenan/feat/turbopack-category-save

feat: add sidebar account menu and auth guard
This commit is contained in:
esdrasrenan 2025-10-06 11:53:00 -03:00 committed by GitHub
commit 675caf68bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 533 additions and 172 deletions

View file

@ -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 PTBR, toasts com feedback, atualizações otimistas com rollback em caso de erro.
- **Git/PR:** branches descritivas, checklist padrão (tipos Convex, labels PTBR, 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 # Plano de Desenvolvimento - Sistema de Chamados
## Meta imediata ## Meta imediata

View file

@ -5,6 +5,8 @@ import { Id, type Doc } from "./_generated/dataModel";
import { requireCustomer, requireStaff, requireUser } from "./rbac"; import { requireCustomer, requireStaff, requireUser } from "./rbac";
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
const QUEUE_RENAME_LOOKUP: Record<string, string> = { const QUEUE_RENAME_LOOKUP: Record<string, string> = {
"Suporte N1": "Chamados", "Suporte N1": "Chamados",
"suporte-n1": "Chamados", "suporte-n1": "Chamados",
@ -459,6 +461,7 @@ export const create = mutation({
channel: v.string(), channel: v.string(),
queueId: v.optional(v.id("queues")), queueId: v.optional(v.id("queues")),
requesterId: v.id("users"), requesterId: v.id("users"),
assigneeId: v.optional(v.id("users")),
categoryId: v.id("ticketCategories"), categoryId: v.id("ticketCategories"),
subcategoryId: v.id("ticketSubcategories"), subcategoryId: v.id("ticketSubcategories"),
customFields: v.optional( customFields: v.optional(
@ -471,11 +474,34 @@ export const create = mutation({
), ),
}, },
handler: async (ctx, args) => { 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) { if (role === "CUSTOMER" && args.requesterId !== args.actorId) {
throw new ConvexError("Clientes só podem abrir chamados para si mesmos") 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(); const subject = args.subject.trim();
if (subject.length < 3) { if (subject.length < 3) {
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres"); throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
@ -510,7 +536,7 @@ export const create = mutation({
categoryId: args.categoryId, categoryId: args.categoryId,
subcategoryId: args.subcategoryId, subcategoryId: args.subcategoryId,
requesterId: args.requesterId, requesterId: args.requesterId,
assigneeId: undefined, assigneeId: initialAssigneeId,
working: false, working: false,
activeSessionId: undefined, activeSessionId: undefined,
totalWorkedMs: 0, totalWorkedMs: 0,
@ -531,6 +557,16 @@ export const create = mutation({
payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl }, payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl },
createdAt: now, 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; return id;
}, },
}); });
@ -558,10 +594,23 @@ export const addComment = mutation({
throw new ConvexError("Ticket não encontrado") 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) { if (ticket.requesterId === args.authorId) {
await requireCustomer(ctx, args.authorId, ticket.tenantId) if (normalizedRole === "CUSTOMER") {
if (args.visibility !== "PUBLIC") { await requireCustomer(ctx, args.authorId, ticket.tenantId)
throw new ConvexError("Clientes só podem registrar comentários públicos") 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 { } else {
await requireStaff(ctx, args.authorId, ticket.tenantId) await requireStaff(ctx, args.authorId, ticket.tenantId)
@ -577,11 +626,10 @@ export const addComment = mutation({
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
const author = await ctx.db.get(args.authorId);
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId: args.ticketId, ticketId: args.ticketId,
type: "COMMENT_ADDED", 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, createdAt: now,
}); });
// bump ticket updatedAt // bump ticket updatedAt

View file

@ -1,5 +1,8 @@
import { mutation, query } from "./_generated/server"; 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({ export const ensureUser = mutation({
args: { args: {
@ -69,11 +72,41 @@ export const ensureUser = mutation({
export const listAgents = query({ export const listAgents = query({
args: { tenantId: v.string() }, args: { tenantId: v.string() },
handler: async (ctx, { tenantId }) => { handler: async (ctx, { tenantId }) => {
const agents = await ctx.db const users = await ctx.db
.query("users") .query("users")
.withIndex("by_tenant_role", (q) => q.eq("tenantId", tenantId).eq("role", "AGENT")) .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect(); .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" };
}, },
}); });

BIN
web/public/rever-8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -20,6 +20,9 @@ const jetBrainsMono = JetBrains_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Sistema de chamados", title: "Sistema de chamados",
description: "Plataforma de chamados da Rever", description: "Plataforma de chamados da Rever",
icons: {
icon: "/rever-8.png",
},
} }
export default async function RootLayout({ export default async function RootLayout({

View file

@ -1,6 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { GalleryVerticalEnd } from "lucide-react" import { GalleryVerticalEnd } from "lucide-react"
@ -22,10 +23,11 @@ export default function LoginPage() {
const [isHydrated, setIsHydrated] = useState(false) const [isHydrated, setIsHydrated] = useState(false)
useEffect(() => { useEffect(() => {
if (isPending) return
if (!session?.user) return if (!session?.user) return
const destination = callbackUrl ?? "/dashboard" const destination = callbackUrl ?? "/dashboard"
router.replace(destination) router.replace(destination)
}, [callbackUrl, router, session?.user]) }, [callbackUrl, isPending, router, session?.user])
useEffect(() => { useEffect(() => {
setIsHydrated(true) setIsHydrated(true)
@ -36,12 +38,12 @@ export default function LoginPage() {
return ( return (
<div className="grid min-h-svh lg:grid-cols-2"> <div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-6 p-6 md:p-10"> <div className="flex flex-col gap-6 p-6 md:p-10">
<div className="flex justify-center gap-2"> <div className="flex flex-col items-center gap-1.5 text-center">
<Link href="/" className="flex items-center gap-2 font-medium"> <Link href="/" className="flex items-center gap-2 text-xl font-semibold text-neutral-900">
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"> <div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
<GalleryVerticalEnd className="size-4" /> <GalleryVerticalEnd className="size-4" />
</div> </div>
Sistema de Chamados Sistema de chamados
</Link> </Link>
</div> </div>
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
@ -49,6 +51,19 @@ export default function LoginPage() {
<LoginForm callbackUrl={callbackUrl} disabled={shouldDisable} /> <LoginForm callbackUrl={callbackUrl} disabled={shouldDisable} />
</div> </div>
</div> </div>
<div className="flex justify-center">
<Image
src="/rever-8.png"
alt="Logotipo Rever Tecnologia"
width={110}
height={110}
className="h-[3.45rem] w-auto"
priority
/>
</div>
<footer className="flex justify-center text-sm text-neutral-500">
Desenvolvido por Esdras Renan
</footer>
</div> </div>
<div className="relative hidden overflow-hidden lg:flex"> <div className="relative hidden overflow-hidden lg:flex">
<ShaderBackground className="h-full w-full" /> <ShaderBackground className="h-full w-full" />

View file

@ -1,17 +1,22 @@
import { AppShell } from "@/components/app-shell"
import { BacklogReport } from "@/components/reports/backlog-report" import { BacklogReport } from "@/components/reports/backlog-report"
import { SiteHeader } from "@/components/site-header"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default function ReportsBacklogPage() { export default function ReportsBacklogPage() {
return ( return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0"> <AppShell
<header className="mb-8 space-y-2"> header={
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Backlog e Prioridades</h1> <SiteHeader
<p className="text-sm text-neutral-600"> title="Backlog e Prioridades"
Avalie o volume de tickets em aberto, prioridades e filas mais pressionadas. lead="Avalie o volume de tickets em aberto, prioridades e filas mais pressionadas."
</p> />
</header> }
<BacklogReport /> >
</main> <div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<BacklogReport />
</div>
</AppShell>
) )
} }

View file

@ -1,17 +1,22 @@
import { AppShell } from "@/components/app-shell"
import { CsatReport } from "@/components/reports/csat-report" import { CsatReport } from "@/components/reports/csat-report"
import { SiteHeader } from "@/components/site-header"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default function ReportsCsatPage() { export default function ReportsCsatPage() {
return ( return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0"> <AppShell
<header className="mb-8 space-y-2"> header={
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Relatório de CSAT</h1> <SiteHeader
<p className="text-sm text-neutral-600"> title="Relatório de CSAT"
Visualize a satisfação dos clientes e identifique pontos de melhoria na entrega. lead="Visualize a satisfação dos clientes e identifique pontos de melhoria na entrega."
</p> />
</header> }
<CsatReport /> >
</main> <div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<CsatReport />
</div>
</AppShell>
) )
} }

View file

@ -1,17 +1,22 @@
import { AppShell } from "@/components/app-shell"
import { SlaReport } from "@/components/reports/sla-report" import { SlaReport } from "@/components/reports/sla-report"
import { SiteHeader } from "@/components/site-header"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default function ReportsSlaPage() { export default function ReportsSlaPage() {
return ( return (
<main className="mx-auto w-full max-w-6xl px-4 py-10 lg:px-0"> <AppShell
<header className="mb-8 space-y-2"> header={
<h1 className="text-3xl font-semibold tracking-tight text-neutral-900">Relatório de SLA</h1> <SiteHeader
<p className="text-sm text-neutral-600"> title="Relatório de SLA"
Acompanhe tempos de resposta, resolução e balanço de filas em tempo real. lead="Acompanhe tempos de resposta, resolução e balanço de filas em tempo real."
</p> />
</header> }
<SlaReport /> >
</main> <div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<SlaReport />
</div>
</AppShell>
) )
} }

View file

@ -1,10 +1,10 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner" 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 type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
@ -40,12 +40,18 @@ export default function NewTicketPage() {
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw]) const queues = useMemo(() => queuesRaw ?? [], [queuesRaw])
const create = useMutation(api.tickets.create) const create = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment) 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 [subject, setSubject] = useState("")
const [summary, setSummary] = useState("") const [summary, setSummary] = useState("")
const [priority, setPriority] = useState<TicketPriority>("MEDIUM") const [priority, setPriority] = useState<TicketPriority>("MEDIUM")
const [channel, setChannel] = useState("MANUAL") const [channel, setChannel] = useState("MANUAL")
const [queueName, setQueueName] = useState<string | null>(null) const [queueName, setQueueName] = useState<string | null>(null)
const [assigneeId, setAssigneeId] = useState<string | null>(null)
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [subjectError, setSubjectError] = useState<string | null>(null) const [subjectError, setSubjectError] = useState<string | null>(null)
@ -53,8 +59,17 @@ export default function NewTicketPage() {
const [subcategoryId, setSubcategoryId] = useState<string | null>(null) const [subcategoryId, setSubcategoryId] = useState<string | null>(null)
const [categoryError, setCategoryError] = useState<string | null>(null) const [categoryError, setCategoryError] = useState<string | null>(null)
const [subcategoryError, setSubcategoryError] = useState<string | null>(null) const [subcategoryError, setSubcategoryError] = useState<string | null>(null)
const [assigneeInitialized, setAssigneeInitialized] = useState(false)
const queueOptions = useMemo(() => queues.map((q) => q.name), [queues]) 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) { async function submit(event: React.FormEvent) {
event.preventDefault() event.preventDefault()
@ -81,6 +96,7 @@ export default function NewTicketPage() {
try { try {
const selQueue = queues.find((q) => q.name === queueName) const selQueue = queues.find((q) => q.name === queueName)
const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined
const assigneeToSend = assigneeId ? (assigneeId as Id<"users">) : undefined
const id = await create({ const id = await create({
actorId: convexUserId as Id<"users">, actorId: convexUserId as Id<"users">,
tenantId: DEFAULT_TENANT_ID, tenantId: DEFAULT_TENANT_ID,
@ -90,6 +106,7 @@ export default function NewTicketPage() {
channel, channel,
queueId, queueId,
requesterId: convexUserId as Id<"users">, requesterId: convexUserId as Id<"users">,
assigneeId: assigneeToSend,
categoryId: categoryId as Id<"ticketCategories">, categoryId: categoryId as Id<"ticketCategories">,
subcategoryId: subcategoryId as Id<"ticketSubcategories">, 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" 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} value={summary}
onChange={(event) => setSummary(event.target.value)} onChange={(event) => setSummary(event.target.value)}
placeholder="Resuma rapidamente o cenário ou impacto do ticket."
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -177,7 +195,7 @@ export default function NewTicketPage() {
</div> </div>
) : null} ) : null}
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium text-neutral-700">Prioridade</span> <span className="text-sm font-medium text-neutral-700">Prioridade</span>
<Select value={priority} onValueChange={(value) => setPriority(value as TicketPriority)}> <Select value={priority} onValueChange={(value) => setPriority(value as TicketPriority)}>
@ -247,6 +265,24 @@ export default function NewTicketPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2">
<span className="text-sm font-medium text-neutral-700">Responsável</span>
<Select value={assigneeSelectValue} onValueChange={(value) => setAssigneeId(value === "NONE" ? null : value)}>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder={staff.length === 0 ? "Carregando..." : "Selecione o responsável"} />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
<SelectItem value="NONE" className={selectItemClass}>
Sem responsável
</SelectItem>
{staff.map((member) => (
<SelectItem key={member._id} value={member._id} className={selectItemClass}>
{member.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button

View file

@ -1,6 +1,7 @@
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { AppSidebar } from "@/components/app-sidebar" import { AppSidebar } from "@/components/app-sidebar"
import { AuthGuard } from "@/components/auth/auth-guard"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
interface AppShellProps { interface AppShellProps {
@ -13,6 +14,7 @@ export function AppShell({ header, children }: AppShellProps) {
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<SidebarInset> <SidebarInset>
<AuthGuard />
{header} {header}
<main className="flex flex-1 flex-col gap-8 bg-gradient-to-br from-background via-background to-primary/10 pb-12 pt-6"> <main className="flex flex-1 flex-col gap-8 bg-gradient-to-br from-background via-background to-primary/10 pb-12 pt-6">
{children} {children}

View file

@ -21,19 +21,21 @@ import { usePathname } from "next/navigation"
import { SearchForm } from "@/components/search-form" import { SearchForm } from "@/components/search-form"
import { VersionSwitcher } from "@/components/version-switcher" import { VersionSwitcher } from "@/components/version-switcher"
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarGroup, SidebarFooter,
SidebarGroupContent, SidebarGroup,
SidebarGroupLabel, SidebarGroupContent,
SidebarHeader, SidebarGroupLabel,
SidebarMenu, SidebarHeader,
SidebarMenuButton, SidebarMenu,
SidebarMenuItem, SidebarMenuButton,
SidebarRail, SidebarMenuItem,
} from "@/components/ui/sidebar" SidebarRail,
} from "@/components/ui/sidebar"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { NavUser } from "@/components/nav-user"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import type { LucideIcon } from "lucide-react" import type { LucideIcon } from "lucide-react"
@ -103,7 +105,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname() const pathname = usePathname()
const { isAdmin, isStaff, isCustomer } = useAuth() const { session, isLoading, isAdmin, isStaff, isCustomer } = useAuth()
const [isHydrated, setIsHydrated] = React.useState(false) const [isHydrated, setIsHydrated] = React.useState(false)
React.useEffect(() => { React.useEffect(() => {
@ -194,6 +196,25 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
) )
})} })}
</SidebarContent> </SidebarContent>
<SidebarFooter>
{isLoading ? (
<div className="flex items-center gap-3 rounded-lg border border-border/70 bg-sidebar p-3 shadow-sm">
<Skeleton className="h-9 w-9 rounded-lg" />
<div className="flex-1 space-y-1">
<Skeleton className="h-3.5 w-24 rounded" />
<Skeleton className="h-3 w-32 rounded" />
</div>
</div>
) : (
<NavUser
user={{
name: session?.user?.name,
email: session?.user?.email,
avatarUrl: session?.user?.avatarUrl ?? undefined,
}}
/>
)}
</SidebarFooter>
<SidebarRail /> <SidebarRail />
</Sidebar> </Sidebar>
) )

View file

@ -0,0 +1,29 @@
"use client"
import { useEffect } from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useAuth } from "@/lib/auth-client"
export function AuthGuard() {
const { session, isLoading } = useAuth()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
if (isLoading) return
if (session?.user) return
const search = searchParams?.toString()
const callbackUrl = pathname
? search && search.length > 0
? `${pathname}?${search}`
: pathname
: undefined
const nextUrl = callbackUrl ? `/login?callbackUrl=${encodeURIComponent(callbackUrl)}` : "/login"
router.replace(nextUrl)
}, [isLoading, session?.user, pathname, searchParams, router])
return null
}

View file

@ -1,110 +1,154 @@
"use client" "use client"
import { import { useCallback, useMemo, useState } from "react"
IconCreditCard, import { useRouter } from "next/navigation"
IconDotsVertical, import {
IconLogout, IconDotsVertical,
IconNotification, IconLogout,
IconUserCircle, IconNotification,
} from "@tabler/icons-react" IconUserCircle,
} from "@tabler/icons-react"
import { import { toast } from "sonner"
Avatar,
AvatarFallback, import {
AvatarImage, Avatar,
} from "@/components/ui/avatar" AvatarFallback,
import { AvatarImage,
DropdownMenu, } from "@/components/ui/avatar"
DropdownMenuContent, import {
DropdownMenuGroup, DropdownMenu,
DropdownMenuItem, DropdownMenuContent,
DropdownMenuLabel, DropdownMenuGroup,
DropdownMenuSeparator, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuLabel,
} from "@/components/ui/dropdown-menu" DropdownMenuSeparator,
import { DropdownMenuTrigger,
SidebarMenu, } from "@/components/ui/dropdown-menu"
SidebarMenuButton, import {
SidebarMenuItem, SidebarMenu,
useSidebar, SidebarMenuButton,
} from "@/components/ui/sidebar" SidebarMenuItem,
useSidebar,
export function NavUser({ } from "@/components/ui/sidebar"
user, import { signOut } from "@/lib/auth-client"
}: {
user: { type NavUserProps = {
name: string user?: {
email: string name?: string | null
avatar: string email?: string | null
} avatarUrl?: string | null
}) { } | null
const { isMobile } = useSidebar() }
return ( export function NavUser({ user }: NavUserProps) {
<SidebarMenu> const normalizedUser = user ?? { name: null, email: null, avatarUrl: null }
<SidebarMenuItem> const { isMobile } = useSidebar()
<DropdownMenu> const router = useRouter()
<DropdownMenuTrigger asChild> const [isSigningOut, setIsSigningOut] = useState(false)
<SidebarMenuButton
size="lg" const initials = useMemo(() => {
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" const source = normalizedUser.name?.trim() || normalizedUser.email?.trim() || ""
> if (!source) return "US"
<Avatar className="h-8 w-8 rounded-lg grayscale"> const parts = source.split(" ").filter(Boolean)
<AvatarImage src={user.avatar} alt={user.name} /> const firstTwo = parts.slice(0, 2).map((part) => part[0]).join("")
<AvatarFallback className="rounded-lg">CN</AvatarFallback> if (firstTwo) return firstTwo.toUpperCase()
</Avatar> return source.slice(0, 2).toUpperCase()
<div className="grid flex-1 text-left text-sm leading-tight"> }, [normalizedUser.name, normalizedUser.email])
<span className="truncate font-medium">{user.name}</span>
<span className="text-muted-foreground truncate text-xs"> const displayName = normalizedUser.name?.trim() || "Usuário"
{user.email} const displayEmail = normalizedUser.email?.trim() || "Sem e-mail definido"
</span>
</div> const handleProfile = useCallback(() => {
<IconDotsVertical className="ml-auto size-4" /> router.push("/settings")
</SidebarMenuButton> }, [router])
</DropdownMenuTrigger>
<DropdownMenuContent const handleSignOut = useCallback(async () => {
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg" if (isSigningOut) return
side={isMobile ? "bottom" : "right"} setIsSigningOut(true)
align="end" try {
sideOffset={4} await signOut()
> toast.success("Sessão encerrada.")
<DropdownMenuLabel className="p-0 font-normal"> router.replace("/login")
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> } catch (error) {
<Avatar className="h-8 w-8 rounded-lg"> console.error("Erro ao encerrar sessão", error)
<AvatarImage src={user.avatar} alt={user.name} /> toast.error("Não foi possível encerrar a sessão.")
<AvatarFallback className="rounded-lg">CN</AvatarFallback> } finally {
</Avatar> setIsSigningOut(false)
<div className="grid flex-1 text-left text-sm leading-tight"> }
<span className="truncate font-medium">{user.name}</span> }, [isSigningOut, router])
<span className="text-muted-foreground truncate text-xs">
{user.email} return (
</span> <SidebarMenu>
</div> <SidebarMenuItem>
</div> <DropdownMenu>
</DropdownMenuLabel> <DropdownMenuTrigger asChild>
<DropdownMenuSeparator /> <SidebarMenuButton
<DropdownMenuGroup> size="lg"
<DropdownMenuItem> className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
<IconUserCircle /> >
Account <Avatar className="h-8 w-8 rounded-lg grayscale">
</DropdownMenuItem> <AvatarImage src={normalizedUser.avatarUrl ?? undefined} alt={displayName} />
<DropdownMenuItem> <AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
<IconCreditCard /> </Avatar>
Billing <div className="grid flex-1 text-left text-sm leading-tight">
</DropdownMenuItem> <span className="truncate font-medium">{displayName}</span>
<DropdownMenuItem> <span className="text-muted-foreground truncate text-xs">
<IconNotification /> {displayEmail}
Notifications </span>
</DropdownMenuItem> </div>
</DropdownMenuGroup> <IconDotsVertical className="ml-auto size-4" />
<DropdownMenuSeparator /> </SidebarMenuButton>
<DropdownMenuItem> </DropdownMenuTrigger>
<IconLogout /> <DropdownMenuContent
Log out className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
</DropdownMenuItem> side={isMobile ? "bottom" : "right"}
</DropdownMenuContent> align="end"
</DropdownMenu> sideOffset={4}
</SidebarMenuItem> >
</SidebarMenu> <DropdownMenuLabel className="p-0 font-normal">
) <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
} <Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={normalizedUser.avatarUrl ?? undefined} alt={displayName} />
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{displayName}</span>
<span className="text-muted-foreground truncate text-xs">
{displayEmail}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
handleProfile()
}}
>
<IconUserCircle className="size-4" />
<span>Meu perfil</span>
</DropdownMenuItem>
<DropdownMenuItem disabled>
<IconNotification className="size-4" />
<span>Notificações (em breve)</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
handleSignOut()
}}
disabled={isSigningOut}
>
<IconLogout className="size-4" />
<span>{isSigningOut ? "Encerrando…" : "Encerrar sessão"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}

View file

@ -2,7 +2,7 @@
import { type ReactNode, useMemo, useState } from "react" import { type ReactNode, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { LogOut, PlusCircle } from "lucide-react" import { LogOut, PlusCircle } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
@ -22,6 +22,7 @@ const navItems = [
export function PortalShell({ children }: PortalShellProps) { export function PortalShell({ children }: PortalShellProps) {
const pathname = usePathname() const pathname = usePathname()
const router = useRouter()
const { session, isCustomer } = useAuth() const { session, isCustomer } = useAuth()
const [isSigningOut, setIsSigningOut] = useState(false) const [isSigningOut, setIsSigningOut] = useState(false)
@ -41,9 +42,11 @@ export function PortalShell({ children }: PortalShellProps) {
try { try {
await signOut() await signOut()
toast.success("Sessão encerrada", { id: "portal-signout" }) toast.success("Sessão encerrada", { id: "portal-signout" })
router.replace("/login")
} catch (error) { } catch (error) {
console.error(error) console.error(error)
toast.error("Não foi possível encerrar a sessão", { id: "portal-signout" }) toast.error("Não foi possível encerrar a sessão", { id: "portal-signout" })
} finally {
setIsSigningOut(false) setIsSigningOut(false)
} }
} }

View file

@ -2,6 +2,7 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner" import { toast } from "sonner"
import { Settings2, Share2, ShieldCheck, UserCog, UserPlus, Users2, Layers3 } from "lucide-react" import { Settings2, Share2, ShieldCheck, UserCog, UserPlus, Users2, Layers3 } from "lucide-react"
@ -87,6 +88,7 @@ const SETTINGS_ACTIONS: SettingsAction[] = [
export function SettingsContent() { export function SettingsContent() {
const { session, isAdmin, isStaff } = useAuth() const { session, isAdmin, isStaff } = useAuth()
const [isSigningOut, setIsSigningOut] = useState(false) const [isSigningOut, setIsSigningOut] = useState(false)
const router = useRouter()
const normalizedRole = session?.user.role?.toLowerCase() ?? "agent" const normalizedRole = session?.user.role?.toLowerCase() ?? "agent"
const roleLabel = ROLE_LABELS[normalizedRole] ?? "Agente" const roleLabel = ROLE_LABELS[normalizedRole] ?? "Agente"
@ -106,9 +108,12 @@ export function SettingsContent() {
setIsSigningOut(true) setIsSigningOut(true)
try { try {
await signOut() await signOut()
toast.success("Sessão encerrada")
router.replace("/login")
} catch (error) { } catch (error) {
console.error(error) console.error(error)
toast.error("Não foi possível encerrar a sessão.") toast.error("Não foi possível encerrar a sessão.")
} finally {
setIsSigningOut(false) setIsSigningOut(false)
} }
} }

View file

@ -1,8 +1,8 @@
"use client" "use client"
import { z } from "zod" import { z } from "zod"
import { useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import type { Id } from "@/convex/_generated/dataModel" import type { Doc, Id } from "@/convex/_generated/dataModel"
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket" import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript definitions // @ts-expect-error Convex runtime API lacks TypeScript definitions
@ -33,6 +33,7 @@ const schema = z.object({
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"), priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"), channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
queueName: z.string().nullable().optional(), queueName: z.string().nullable().optional(),
assigneeId: z.string().nullable().optional(),
categoryId: z.string().min(1, "Selecione uma categoria"), categoryId: z.string().min(1, "Selecione uma categoria"),
subcategoryId: z.string().min(1, "Selecione uma categoria secundária"), subcategoryId: z.string().min(1, "Selecione uma categoria secundária"),
}) })
@ -49,6 +50,7 @@ export function NewTicketDialog() {
priority: "MEDIUM", priority: "MEDIUM",
channel: "MANUAL", channel: "MANUAL",
queueName: null, queueName: null,
assigneeId: null,
categoryId: "", categoryId: "",
subcategoryId: "", subcategoryId: "",
}, },
@ -65,15 +67,34 @@ export function NewTicketDialog() {
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw]) const queues = useMemo(() => queuesRaw ?? [], [queuesRaw])
const create = useMutation(api.tickets.create) const create = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment) 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 [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([]) const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
const priorityValue = form.watch("priority") as TicketPriority const priorityValue = form.watch("priority") as TicketPriority
const channelValue = form.watch("channel") const channelValue = form.watch("channel")
const queueValue = form.watch("queueName") ?? "NONE" const queueValue = form.watch("queueName") ?? "NONE"
const assigneeValue = form.watch("assigneeId") ?? null
const assigneeSelectValue = assigneeValue ?? "NONE"
const categoryIdValue = form.watch("categoryId") const categoryIdValue = form.watch("categoryId")
const subcategoryIdValue = form.watch("subcategoryId") const subcategoryIdValue = form.watch("subcategoryId")
const isSubmitted = form.formState.isSubmitted const isSubmitted = form.formState.isSubmitted
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 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" 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"
const [assigneeInitialized, setAssigneeInitialized] = useState(false)
useEffect(() => {
if (!open) {
setAssigneeInitialized(false)
return
}
if (assigneeInitialized) return
if (!convexUserId) return
form.setValue("assigneeId", convexUserId, { shouldDirty: false, shouldTouch: false })
setAssigneeInitialized(true)
}, [open, assigneeInitialized, convexUserId, form])
const handleCategoryChange = (value: string) => { const handleCategoryChange = (value: string) => {
const previous = form.getValues("categoryId") ?? "" const previous = form.getValues("categoryId") ?? ""
@ -112,6 +133,7 @@ export function NewTicketDialog() {
toast.loading("Criando ticket…", { id: "new-ticket" }) toast.loading("Criando ticket…", { id: "new-ticket" })
try { try {
const sel = queues.find((q) => q.name === values.queueName) const sel = queues.find((q) => q.name === values.queueName)
const selectedAssignee = form.getValues("assigneeId") ?? null
const id = await create({ const id = await create({
actorId: convexUserId as Id<"users">, actorId: convexUserId as Id<"users">,
tenantId: DEFAULT_TENANT_ID, tenantId: DEFAULT_TENANT_ID,
@ -121,6 +143,7 @@ export function NewTicketDialog() {
channel: values.channel, channel: values.channel,
queueId: sel?.id as Id<"queues"> | undefined, queueId: sel?.id as Id<"queues"> | undefined,
requesterId: convexUserId as Id<"users">, requesterId: convexUserId as Id<"users">,
assigneeId: selectedAssignee ? (selectedAssignee as Id<"users">) : undefined,
categoryId: values.categoryId as Id<"ticketCategories">, categoryId: values.categoryId as Id<"ticketCategories">,
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">, subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
}) })
@ -138,6 +161,7 @@ export function NewTicketDialog() {
toast.success("Ticket criado!", { id: "new-ticket" }) toast.success("Ticket criado!", { id: "new-ticket" })
setOpen(false) setOpen(false)
form.reset() form.reset()
setAssigneeInitialized(false)
setAttachments([]) setAttachments([])
// Navegar para o ticket recém-criado // Navegar para o ticket recém-criado
window.location.href = `/tickets/${id}` window.location.href = `/tickets/${id}`
@ -199,6 +223,7 @@ export function NewTicketDialog() {
id="summary" id="summary"
className="min-h-[96px] w-full rounded-lg border border-slate-300 bg-background p-3 text-sm shadow-sm focus-visible:border-[#00d6eb] focus-visible:outline-none" className="min-h-[96px] w-full rounded-lg border border-slate-300 bg-background p-3 text-sm shadow-sm focus-visible:border-[#00d6eb] focus-visible:outline-none"
{...form.register("summary")} {...form.register("summary")}
placeholder="Explique em poucas linhas o contexto do chamado."
/> />
</Field> </Field>
<Field> <Field>
@ -304,6 +329,32 @@ export function NewTicketDialog() {
</SelectContent> </SelectContent>
</Select> </Select>
</Field> </Field>
<Field>
<FieldLabel>Responsável</FieldLabel>
<Select
value={assigneeSelectValue}
onValueChange={(value) =>
form.setValue("assigneeId", value === "NONE" ? null : value, {
shouldDirty: value !== assigneeValue,
shouldTouch: true,
})
}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder={staff.length === 0 ? "Carregando..." : "Selecione o responsável"} />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
<SelectItem value="NONE" className={selectItemClass}>
Sem responsável
</SelectItem>
{staff.map((member) => (
<SelectItem key={member._id} value={member._id} className={selectItemClass}>
{member.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div> </div>
</div> </div>
</FieldGroup> </FieldGroup>