chore: reorganize project structure and ensure default queues

This commit is contained in:
Esdras Renan 2025-10-06 22:59:35 -03:00
parent 854887f499
commit 1cccb852a5
201 changed files with 417 additions and 838 deletions

View file

@ -0,0 +1,16 @@
"use client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode } from "react";
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
const client = convexUrl ? new ConvexReactClient(convexUrl) : undefined;
export function ConvexClientProvider({ children }: { children: ReactNode }) {
if (!convexUrl) {
return <>{children}</>;
}
return <ConvexProvider client={client!}>{children}</ConvexProvider>;
}

View file

@ -0,0 +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 (
<AppShell
header={
<SiteHeader
title="Filas e canais"
lead="Configure as filas internas e vincule-as aos times responsáveis por cada canal de atendimento."
/>
}
>
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
<QueuesManager />
</div>
</AppShell>
)
}

View file

@ -0,0 +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 (
<AppShell
header={
<SiteHeader
title="Categorias e campos personalizados"
lead="Administre as categorias primárias/secundárias e os campos adicionais aplicados aos tickets."
/>
}
>
<div className="mx-auto w-full max-w-6xl space-y-8 px-6 lg:px-8">
<CategoriesManager />
<FieldsManager />
</div>
</AppShell>
)
}

11
src/app/admin/layout.tsx Normal file
View file

@ -0,0 +1,11 @@
import { ReactNode } from "react"
import { requireAdminSession } from "@/lib/auth-server"
export const dynamic = "force-dynamic"
export const runtime = "nodejs"
export default async function AdminLayout({ children }: { children: ReactNode }) {
await requireAdminSession()
return <>{children}</>
}

79
src/app/admin/page.tsx Normal file
View file

@ -0,0 +1,79 @@
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"
async function loadUsers() {
const users = await prisma.authUser.findMany({
orderBy: { createdAt: "desc" },
select: {
id: true,
email: true,
name: true,
role: true,
tenantId: true,
createdAt: true,
updatedAt: true,
},
})
return users.map((user) => ({
id: user.id,
email: user.email,
name: user.name ?? "",
role: normalizeRole(user.role) ?? "agent",
tenantId: user.tenantId ?? DEFAULT_TENANT_ID,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt?.toISOString() ?? null,
}))
}
async function loadInvites(): Promise<NormalizedInvite[]> {
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 (
<AppShell
header={
<SiteHeader
title="Administração"
lead="Convide novos membros, ajuste papéis e organize as filas e categorias de atendimento."
/>
}
>
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
<AdminUsersManager
initialUsers={users}
initialInvites={invitesForClient}
roleOptions={ROLE_OPTIONS}
defaultTenantId={DEFAULT_TENANT_ID}
/>
</div>
</AppShell>
)
}

View file

@ -0,0 +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 (
<AppShell
header={
<SiteHeader
title="Políticas de SLA"
lead="Configure tempos de resposta e resolução para garantir a cobertura dos acordos de serviço."
/>
}
>
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
<SlasManager />
</div>
</AppShell>
)
}

View file

@ -0,0 +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 (
<AppShell
header={
<SiteHeader
title="Times e agentes"
lead="Estruture squads, capítulos e equipes responsáveis pelos tickets antes de associar filas e SLAs."
/>
}
>
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
<TeamsManager />
</div>
</AppShell>
)
}

View file

@ -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<RevokePayload> | 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 })
}

View file

@ -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<CreateInvitePayload> | 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 })
}

View file

@ -0,0 +1,122 @@
import { NextResponse } from "next/server"
import { randomBytes } from "crypto"
import { hashPassword } from "better-auth/crypto"
import { ConvexHttpClient } from "convex/browser"
// @ts-expect-error Convex generated API lacks type declarations in Next API routes
import { api } from "@/convex/_generated/api"
import { prisma } from "@/lib/prisma"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { assertAdminSession } from "@/lib/auth-server"
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
export const runtime = "nodejs"
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 generatePassword(length = 12) {
const bytes = randomBytes(length)
return Array.from(bytes)
.map((byte) => (byte % 36).toString(36))
.join("")
}
export async function GET() {
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const users = await prisma.authUser.findMany({
orderBy: { createdAt: "desc" },
select: {
id: true,
email: true,
name: true,
role: true,
tenantId: true,
createdAt: true,
updatedAt: true,
},
})
return NextResponse.json({ users })
}
export async function POST(request: Request) {
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const payload = await request.json().catch(() => null)
if (!payload || typeof payload !== "object") {
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
}
const emailInput = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : ""
const nameInput = typeof payload.name === "string" ? payload.name.trim() : ""
const roleInput = typeof payload.role === "string" ? payload.role : undefined
const tenantInput = typeof payload.tenantId === "string" ? payload.tenantId.trim() : undefined
if (!emailInput || !emailInput.includes("@")) {
return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 })
}
const role = normalizeRole(roleInput)
const tenantId = tenantInput || session.user.tenantId || DEFAULT_TENANT_ID
const existing = await prisma.authUser.findUnique({ where: { email: emailInput } })
if (existing) {
return NextResponse.json({ error: "Já existe um usuário com este e-mail" }, { status: 409 })
}
const password = generatePassword()
const hashedPassword = await hashPassword(password)
const user = await prisma.authUser.create({
data: {
email: emailInput,
name: nameInput || emailInput,
role,
tenantId,
accounts: {
create: {
providerId: "credential",
accountId: emailInput,
password: hashedPassword,
},
},
},
select: {
id: true,
email: true,
name: true,
role: true,
tenantId: true,
createdAt: true,
},
})
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
if (convexUrl) {
try {
const convex = new ConvexHttpClient(convexUrl)
await convex.mutation(api.users.ensureUser, {
tenantId,
email: emailInput,
name: nameInput || emailInput,
avatarUrl: undefined,
role: role.toUpperCase(),
})
} catch (error) {
console.warn("Falha ao sincronizar usuário no Convex", error)
}
}
return NextResponse.json({ user, temporaryPassword: password })
}

View file

@ -0,0 +1,5 @@
import { toNextJsHandler } from "better-auth/next-js"
import { auth } from "@/lib/auth"
export const { GET, POST } = toNextJsHandler(auth.handler)

View file

@ -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<AcceptInvitePayload> | 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 })
}

614
src/app/dashboard/data.json Normal file
View file

@ -0,0 +1,614 @@
[
{
"id": 1,
"header": "Cover page",
"type": "Cover page",
"status": "In Process",
"target": "18",
"limit": "5",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Table of contents",
"type": "Table of contents",
"status": "Done",
"target": "29",
"limit": "24",
"reviewer": "Eddie Lake"
},
{
"id": 3,
"header": "Executive summary",
"type": "Narrative",
"status": "Done",
"target": "10",
"limit": "13",
"reviewer": "Eddie Lake"
},
{
"id": 4,
"header": "Technical approach",
"type": "Narrative",
"status": "Done",
"target": "27",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 5,
"header": "Design",
"type": "Narrative",
"status": "In Process",
"target": "2",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 6,
"header": "Capabilities",
"type": "Narrative",
"status": "In Process",
"target": "20",
"limit": "8",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 7,
"header": "Integration with existing systems",
"type": "Narrative",
"status": "In Process",
"target": "19",
"limit": "21",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 8,
"header": "Innovation and Advantages",
"type": "Narrative",
"status": "Done",
"target": "25",
"limit": "26",
"reviewer": "Assign reviewer"
},
{
"id": 9,
"header": "Overview of EMR's Innovative Solutions",
"type": "Technical content",
"status": "Done",
"target": "7",
"limit": "23",
"reviewer": "Assign reviewer"
},
{
"id": 10,
"header": "Advanced Algorithms and Machine Learning",
"type": "Narrative",
"status": "Done",
"target": "30",
"limit": "28",
"reviewer": "Assign reviewer"
},
{
"id": 11,
"header": "Adaptive Communication Protocols",
"type": "Narrative",
"status": "Done",
"target": "9",
"limit": "31",
"reviewer": "Assign reviewer"
},
{
"id": 12,
"header": "Advantages Over Current Technologies",
"type": "Narrative",
"status": "Done",
"target": "12",
"limit": "0",
"reviewer": "Assign reviewer"
},
{
"id": 13,
"header": "Past Performance",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "33",
"reviewer": "Assign reviewer"
},
{
"id": 14,
"header": "Customer Feedback and Satisfaction Levels",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "34",
"reviewer": "Assign reviewer"
},
{
"id": 15,
"header": "Implementation Challenges and Solutions",
"type": "Narrative",
"status": "Done",
"target": "3",
"limit": "35",
"reviewer": "Assign reviewer"
},
{
"id": 16,
"header": "Security Measures and Data Protection Policies",
"type": "Narrative",
"status": "In Process",
"target": "6",
"limit": "36",
"reviewer": "Assign reviewer"
},
{
"id": 17,
"header": "Scalability and Future Proofing",
"type": "Narrative",
"status": "Done",
"target": "4",
"limit": "37",
"reviewer": "Assign reviewer"
},
{
"id": 18,
"header": "Cost-Benefit Analysis",
"type": "Plain language",
"status": "Done",
"target": "14",
"limit": "38",
"reviewer": "Assign reviewer"
},
{
"id": 19,
"header": "User Training and Onboarding Experience",
"type": "Narrative",
"status": "Done",
"target": "17",
"limit": "39",
"reviewer": "Assign reviewer"
},
{
"id": 20,
"header": "Future Development Roadmap",
"type": "Narrative",
"status": "Done",
"target": "11",
"limit": "40",
"reviewer": "Assign reviewer"
},
{
"id": 21,
"header": "System Architecture Overview",
"type": "Technical content",
"status": "In Process",
"target": "24",
"limit": "18",
"reviewer": "Maya Johnson"
},
{
"id": 22,
"header": "Risk Management Plan",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "22",
"reviewer": "Carlos Rodriguez"
},
{
"id": 23,
"header": "Compliance Documentation",
"type": "Legal",
"status": "In Process",
"target": "31",
"limit": "27",
"reviewer": "Sarah Chen"
},
{
"id": 24,
"header": "API Documentation",
"type": "Technical content",
"status": "Done",
"target": "8",
"limit": "12",
"reviewer": "Raj Patel"
},
{
"id": 25,
"header": "User Interface Mockups",
"type": "Visual",
"status": "In Process",
"target": "19",
"limit": "25",
"reviewer": "Leila Ahmadi"
},
{
"id": 26,
"header": "Database Schema",
"type": "Technical content",
"status": "Done",
"target": "22",
"limit": "20",
"reviewer": "Thomas Wilson"
},
{
"id": 27,
"header": "Testing Methodology",
"type": "Technical content",
"status": "In Process",
"target": "17",
"limit": "14",
"reviewer": "Assign reviewer"
},
{
"id": 28,
"header": "Deployment Strategy",
"type": "Narrative",
"status": "Done",
"target": "26",
"limit": "30",
"reviewer": "Eddie Lake"
},
{
"id": 29,
"header": "Budget Breakdown",
"type": "Financial",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 30,
"header": "Market Analysis",
"type": "Research",
"status": "Done",
"target": "29",
"limit": "32",
"reviewer": "Sophia Martinez"
},
{
"id": 31,
"header": "Competitor Comparison",
"type": "Research",
"status": "In Process",
"target": "21",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 32,
"header": "Maintenance Plan",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "23",
"reviewer": "Alex Thompson"
},
{
"id": 33,
"header": "User Personas",
"type": "Research",
"status": "In Process",
"target": "27",
"limit": "24",
"reviewer": "Nina Patel"
},
{
"id": 34,
"header": "Accessibility Compliance",
"type": "Legal",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 35,
"header": "Performance Metrics",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "David Kim"
},
{
"id": 36,
"header": "Disaster Recovery Plan",
"type": "Technical content",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 37,
"header": "Third-party Integrations",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Eddie Lake"
},
{
"id": 38,
"header": "User Feedback Summary",
"type": "Research",
"status": "Done",
"target": "20",
"limit": "15",
"reviewer": "Assign reviewer"
},
{
"id": 39,
"header": "Localization Strategy",
"type": "Narrative",
"status": "In Process",
"target": "12",
"limit": "19",
"reviewer": "Maria Garcia"
},
{
"id": 40,
"header": "Mobile Compatibility",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "James Wilson"
},
{
"id": 41,
"header": "Data Migration Plan",
"type": "Technical content",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Assign reviewer"
},
{
"id": 42,
"header": "Quality Assurance Protocols",
"type": "Technical content",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Priya Singh"
},
{
"id": 43,
"header": "Stakeholder Analysis",
"type": "Research",
"status": "In Process",
"target": "11",
"limit": "14",
"reviewer": "Eddie Lake"
},
{
"id": 44,
"header": "Environmental Impact Assessment",
"type": "Research",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Assign reviewer"
},
{
"id": 45,
"header": "Intellectual Property Rights",
"type": "Legal",
"status": "In Process",
"target": "17",
"limit": "20",
"reviewer": "Sarah Johnson"
},
{
"id": 46,
"header": "Customer Support Framework",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 47,
"header": "Version Control Strategy",
"type": "Technical content",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 48,
"header": "Continuous Integration Pipeline",
"type": "Technical content",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Michael Chen"
},
{
"id": 49,
"header": "Regulatory Compliance",
"type": "Legal",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Assign reviewer"
},
{
"id": 50,
"header": "User Authentication System",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "Eddie Lake"
},
{
"id": 51,
"header": "Data Analytics Framework",
"type": "Technical content",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 52,
"header": "Cloud Infrastructure",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 53,
"header": "Network Security Measures",
"type": "Technical content",
"status": "In Process",
"target": "29",
"limit": "32",
"reviewer": "Lisa Wong"
},
{
"id": 54,
"header": "Project Timeline",
"type": "Planning",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Eddie Lake"
},
{
"id": 55,
"header": "Resource Allocation",
"type": "Planning",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Assign reviewer"
},
{
"id": 56,
"header": "Team Structure and Roles",
"type": "Planning",
"status": "Done",
"target": "20",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 57,
"header": "Communication Protocols",
"type": "Planning",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 58,
"header": "Success Metrics",
"type": "Planning",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Eddie Lake"
},
{
"id": 59,
"header": "Internationalization Support",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 60,
"header": "Backup and Recovery Procedures",
"type": "Technical content",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 61,
"header": "Monitoring and Alerting System",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Daniel Park"
},
{
"id": 62,
"header": "Code Review Guidelines",
"type": "Technical content",
"status": "Done",
"target": "12",
"limit": "15",
"reviewer": "Eddie Lake"
},
{
"id": 63,
"header": "Documentation Standards",
"type": "Technical content",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 64,
"header": "Release Management Process",
"type": "Planning",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Assign reviewer"
},
{
"id": 65,
"header": "Feature Prioritization Matrix",
"type": "Planning",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Emma Davis"
},
{
"id": 66,
"header": "Technical Debt Assessment",
"type": "Technical content",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Eddie Lake"
},
{
"id": 67,
"header": "Capacity Planning",
"type": "Planning",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 68,
"header": "Service Level Agreements",
"type": "Legal",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Assign reviewer"
}
]

View file

@ -0,0 +1,27 @@
import { AppShell } from "@/components/app-shell"
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
import { SectionCards } from "@/components/section-cards"
import { SiteHeader } from "@/components/site-header"
import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel"
export default function Dashboard() {
return (
<AppShell
header={
<SiteHeader
title="Central de operações"
lead="Monitoramento em tempo real"
secondaryAction={<SiteHeader.SecondaryButton>Abrir ticket</SiteHeader.SecondaryButton>}
primaryAction={<SiteHeader.PrimaryButton>Modo play</SiteHeader.PrimaryButton>}
/>
}
>
<SectionCards />
<div className="grid gap-6 px-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:px-6 lg:[&>*]:min-w-0">
<ChartAreaInteractive />
<RecentTicketsPanel />
</div>
</AppShell>
)
}

28
src/app/dev/seed/page.tsx Normal file
View file

@ -0,0 +1,28 @@
"use client";
import { useState } from "react";
import { useMutation } from "convex/react";
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api";
export default function SeedPage() {
const seed = useMutation(api.seed.seedDemo);
const [done, setDone] = useState(false);
return (
<div className="flex min-h-dvh items-center justify-center p-6">
<div className="space-y-4 rounded-xl border bg-card p-6 text-center">
<h1 className="text-lg font-semibold">Popular dados de demonstração</h1>
<button
className="inline-flex items-center justify-center rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground"
onClick={async () => {
await seed({});
setDone(true);
}}
>
Executar seed
</button>
{done && <p className="text-sm text-green-600">Ok! Abra a página de Tickets.</p>}
</div>
</div>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

182
src/app/globals.css Normal file
View file

@ -0,0 +1,182 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-destructive-foreground: var(--destructive-foreground);
--font-geist-mono: var(----font-geist-mono);
--font-geist-sans: var(----font-geist-sans);
--radius: var(----radius);
}
:root {
--radius: 0.75rem;
--background: #f7f8fb;
--foreground: #0f172a;
--card: #ffffff;
--card-foreground: #0f172a;
--popover: #ffffff;
--popover-foreground: #0f172a;
--primary: #00e8ff;
--primary-foreground: #020617;
--secondary: #0f172a;
--secondary-foreground: #f8fafc;
--muted: #e2e8f0;
--muted-foreground: #475569;
--accent: #dff7fb;
--accent-foreground: #0f172a;
--destructive: #ef4444;
--border: #d6d8de;
--input: #e4e7ec;
--ring: #00d6eb;
--chart-1: #00d6eb;
--chart-2: #0891b2;
--chart-3: #0e7490;
--chart-4: #155e75;
--chart-5: #0f4c5c;
--sidebar: #f2f5f7;
--sidebar-foreground: #0f172a;
--sidebar-primary: #00e8ff;
--sidebar-primary-foreground: #020617;
--sidebar-accent: #c4eef6;
--sidebar-accent-foreground: #0f172a;
--sidebar-border: #cbd5e1;
--sidebar-ring: #00d6eb;
--destructive-foreground: oklch(1 0 0);
--font-geist-sans: "Geist Sans", sans-serif;
--font-geist-mono: "Geist Mono", monospace;
}
.dark {
--background: #020617;
--foreground: #f8fafc;
--card: #0b1120;
--card-foreground: #f8fafc;
--popover: #0b1120;
--popover-foreground: #f8fafc;
--primary: #00d6eb;
--primary-foreground: #041019;
--secondary: #1f2937;
--secondary-foreground: #f9fafb;
--muted: #1e293b;
--muted-foreground: #cbd5f5;
--accent: #083344;
--accent-foreground: #f1f5f9;
--destructive: #f87171;
--border: #1f2933;
--input: #1e2933;
--ring: #00e6ff;
--chart-1: #00e6ff;
--chart-2: #0891b2;
--chart-3: #0e7490;
--chart-4: #155e75;
--chart-5: #0f4c5c;
--sidebar: #050c16;
--sidebar-foreground: #f8fafc;
--sidebar-primary: #00d6eb;
--sidebar-primary-foreground: #041019;
--sidebar-accent: #083344;
--sidebar-accent-foreground: #f8fafc;
--sidebar-border: #0f1b2a;
--sidebar-ring: #00e6ff;
--destructive-foreground: oklch(1 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground font-sans antialiased;
}
}
@layer components {
/* Tipografia básica para conteúdos rich text (Tiptap) */
.rich-text {
@apply text-foreground;
}
.rich-text p { @apply my-2; }
.rich-text a { @apply text-neutral-900 underline; }
.rich-text ul { @apply my-2 list-disc ps-5; }
.rich-text ol { @apply my-2 list-decimal ps-5; }
.rich-text li { @apply my-1; }
.rich-text blockquote { @apply my-3 border-l-2 border-muted-foreground/30 ps-3 text-muted-foreground; }
.rich-text h1 { @apply text-xl font-semibold my-3; }
.rich-text h2 { @apply text-lg font-semibold my-3; }
.rich-text h3 { @apply text-base font-semibold my-2; }
.rich-text code { @apply rounded bg-muted px-1 py-0.5 text-xs; }
.rich-text pre { @apply my-3 overflow-x-auto rounded bg-muted p-3 text-xs; }
.rich-text .ProseMirror.is-editor-empty::before,
.rich-text .ProseMirror p.is-editor-empty:first-child::before {
color: #94a3b8;
content: attr(data-placeholder);
pointer-events: none;
height: 0;
float: left;
font-weight: 400;
}
@keyframes recent-ticket-enter {
0% {
opacity: 0;
transform: translateY(-12px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.recent-ticket-enter {
animation: recent-ticket-enter 0.45s ease-out;
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -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 (
<main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-4 py-16">
<Card className="w-full border border-border/70 shadow-sm">
<CardHeader className="space-y-2 text-center">
<CardTitle className="text-2xl font-semibold text-neutral-900">Aceitar convite</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Conclua seu cadastro para acessar a plataforma Sistema de chamados.
</CardDescription>
</CardHeader>
<CardContent>
<InviteAcceptForm invite={summary} />
</CardContent>
</Card>
</main>
)
}

47
src/app/layout.tsx Normal file
View file

@ -0,0 +1,47 @@
import type { Metadata } from "next"
import { Inter, JetBrains_Mono } from "next/font/google"
import "./globals.css"
import { ConvexClientProvider } from "./ConvexClientProvider"
import { AuthProvider } from "@/lib/auth-client"
import { Toaster } from "@/components/ui/sonner"
const inter = Inter({
subsets: ["latin"],
variable: "--font-geist-sans",
display: "swap",
})
const jetBrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-geist-mono",
display: "swap",
})
export const metadata: Metadata = {
title: "Sistema de chamados",
description: "Plataforma de chamados da Rever",
icons: {
icon: "/rever-8.png",
},
}
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="pt-BR" className="h-full">
<body
className={`${inter.variable} ${jetBrainsMono.variable} min-h-screen bg-background text-foreground antialiased`}
>
<ConvexClientProvider>
<AuthProvider>
{children}
<Toaster position="bottom-center" richColors />
</AuthProvider>
</ConvexClientProvider>
</body>
</html>
)
}

74
src/app/login/page.tsx Normal file
View file

@ -0,0 +1,74 @@
"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"
import { LoginForm } from "@/components/login-form"
import { useSession } from "@/lib/auth-client"
import dynamic from "next/dynamic"
const ShaderBackground = dynamic(
() => import("@/components/background-paper-shaders-wrapper"),
{ ssr: false }
)
export default function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { data: session, isPending } = useSession()
const callbackUrl = searchParams?.get("callbackUrl") ?? undefined
const [isHydrated, setIsHydrated] = useState(false)
useEffect(() => {
if (isPending) return
if (!session?.user) return
const destination = callbackUrl ?? "/dashboard"
router.replace(destination)
}, [callbackUrl, isPending, router, session?.user])
useEffect(() => {
setIsHydrated(true)
}, [])
const shouldDisable = !isHydrated || isPending
return (
<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 items-center gap-1.5 text-center">
<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">
<GalleryVerticalEnd className="size-4" />
</div>
Sistema de chamados
</Link>
</div>
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<LoginForm callbackUrl={callbackUrl} disabled={shouldDisable} />
</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 className="relative hidden overflow-hidden lg:flex">
<ShaderBackground className="h-full w-full" />
</div>
</div>
)
}

5
src/app/page.tsx Normal file
View file

@ -0,0 +1,5 @@
import { redirect } from "next/navigation"
export default function Home() {
redirect("/dashboard")
}

24
src/app/play/page.tsx Normal file
View file

@ -0,0 +1,24 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { PlayNextTicketCard } from "@/components/tickets/play-next-ticket-card"
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
export default function PlayPage() {
return (
<AppShell
header={
<SiteHeader
title="Modo play"
lead="Distribua tickets automaticamente conforme prioridade"
secondaryAction={<SiteHeader.SecondaryButton>Pausar notificacoes</SiteHeader.SecondaryButton>}
primaryAction={<SiteHeader.PrimaryButton>Iniciar sessao</SiteHeader.PrimaryButton>}
/>
}
>
<div className="flex flex-col gap-6 px-4 lg:px-6">
<PlayNextTicketCard />
<TicketQueueSummaryCards />
</div>
</AppShell>
)
}

View file

@ -0,0 +1,7 @@
import type { ReactNode } from "react"
import { PortalShell } from "@/components/portal/portal-shell"
export default function PortalLayout({ children }: { children: ReactNode }) {
return <PortalShell>{children}</PortalShell>
}

11
src/app/portal/page.tsx Normal file
View file

@ -0,0 +1,11 @@
import type { Metadata } from "next"
import { redirect } from "next/navigation"
export const metadata: Metadata = {
title: "Portal do cliente",
description: "Acompanhe seus chamados e atualizações como cliente.",
}
export default function PortalPage() {
redirect("/portal/tickets")
}

View file

@ -0,0 +1,5 @@
import { PortalTicketDetail } from "@/components/portal/portal-ticket-detail"
export default function PortalTicketDetailPage({ params }: { params: { id: string } }) {
return <PortalTicketDetail ticketId={params.id} />
}

View file

@ -0,0 +1,12 @@
import type { Metadata } from "next"
import { PortalTicketForm } from "@/components/portal/portal-ticket-form"
export const metadata: Metadata = {
title: "Abrir chamado",
description: "Registre um novo chamado para a equipe de suporte.",
}
export default function PortalNewTicketPage() {
return <PortalTicketForm />
}

View file

@ -0,0 +1,12 @@
import type { Metadata } from "next"
import { PortalTicketList } from "@/components/portal/portal-ticket-list"
export const metadata: Metadata = {
title: "Meus chamados",
description: "Acompanhe os chamados abertos com a equipe de suporte.",
}
export default function PortalTicketsPage() {
return <PortalTicketList />
}

View file

@ -0,0 +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 (
<AppShell
header={
<SiteHeader
title="Backlog e Prioridades"
lead="Avalie o volume de tickets em aberto, prioridades e filas mais pressionadas."
/>
}
>
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<BacklogReport />
</div>
</AppShell>
)
}

View file

@ -0,0 +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 (
<AppShell
header={
<SiteHeader
title="Relatório de CSAT"
lead="Visualize a satisfação dos clientes e identifique pontos de melhoria na entrega."
/>
}
>
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<CsatReport />
</div>
</AppShell>
)
}

View file

@ -0,0 +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 (
<AppShell
header={
<SiteHeader
title="Relatório de SLA"
lead="Acompanhe tempos de resposta, resolução e balanço de filas em tempo real."
/>
}
>
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<SlaReport />
</div>
</AppShell>
)
}

13
src/app/settings/page.tsx Normal file
View file

@ -0,0 +1,13 @@
import { AppShell } from "@/components/app-shell"
import { SettingsContent } from "@/components/settings/settings-content"
import { SiteHeader } from "@/components/site-header"
export default function SettingsPage() {
return (
<AppShell
header={<SiteHeader title="Configurações" lead="Central de preferências e governança do workspace" />}
>
<SettingsContent />
</AppShell>
)
}

View file

@ -0,0 +1,24 @@
import { AppShell } from "@/components/app-shell"
import { CommentTemplatesManager } from "@/components/settings/comment-templates-manager"
import { SiteHeader } from "@/components/site-header"
import { requireStaffSession } from "@/lib/auth-server"
export const dynamic = "force-dynamic"
export const runtime = "nodejs"
export default async function CommentTemplatesPage() {
await requireStaffSession()
return (
<AppShell
header={
<SiteHeader
title="Templates de comentário"
lead="Mantenha respostas prontas e alinhadas com a voz da Rever."
/>
}
>
<CommentTemplatesManager />
</AppShell>
)
}

View file

@ -0,0 +1,32 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { TicketDetailView } from "@/components/tickets/ticket-detail-view"
import { TicketDetailStatic } from "@/components/tickets/ticket-detail-static"
import { NewTicketDialog } from "@/components/tickets/new-ticket-dialog"
import { getTicketById } from "@/lib/mocks/tickets"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
type TicketDetailPageProps = {
params: Promise<{ id: string }>
}
export default async function TicketDetailPage({ params }: TicketDetailPageProps) {
const { id } = await params
const isMock = id.startsWith("ticket-")
const mock = isMock ? getTicketById(id) : null
return (
<AppShell
header={
<SiteHeader
title={`Ticket #${id}`}
lead={"Detalhes do ticket"}
secondaryAction={<SiteHeader.SecondaryButton>Compartilhar</SiteHeader.SecondaryButton>}
primaryAction={<NewTicketDialog />}
/>
}
>
{isMock && mock ? <TicketDetailStatic ticket={mock as TicketWithDetails} /> : <TicketDetailView id={id} />}
</AppShell>
)
}

View file

@ -0,0 +1,333 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
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"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { RichTextEditor, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { Spinner } from "@/components/ui/spinner"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import {
PriorityIcon,
priorityBadgeClass,
priorityItemClass,
priorityStyles,
priorityTriggerClass,
} from "@/components/tickets/priority-select"
import { CategorySelectFields } from "@/components/tickets/category-select"
export default function NewTicketPage() {
const router = useRouter()
const { convexUserId } = useAuth()
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)
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<TicketPriority>("MEDIUM")
const [channel, setChannel] = useState("MANUAL")
const [queueName, setQueueName] = useState<string | null>(null)
const [assigneeId, setAssigneeId] = useState<string | null>(null)
const [description, setDescription] = useState("")
const [loading, setLoading] = useState(false)
const [subjectError, setSubjectError] = useState<string | null>(null)
const [categoryId, setCategoryId] = useState<string | null>(null)
const [subcategoryId, setSubcategoryId] = useState<string | null>(null)
const [categoryError, setCategoryError] = useState<string | null>(null)
const [subcategoryError, setSubcategoryError] = useState<string | null>(null)
const [descriptionError, setDescriptionError] = useState<string | null>(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()
if (!convexUserId || loading) return
const trimmedSubject = subject.trim()
if (trimmedSubject.length < 3) {
setSubjectError("Informe um assunto com pelo menos 3 caracteres.")
return
}
setSubjectError(null)
if (!categoryId) {
setCategoryError("Selecione uma categoria.")
return
}
if (!subcategoryId) {
setSubcategoryError("Selecione uma categoria secundária.")
return
}
const sanitizedDescription = sanitizeEditorHtml(description)
const plainDescription = sanitizedDescription.replace(/<[^>]*>/g, "").trim()
if (plainDescription.length === 0) {
setDescriptionError("Descreva o contexto do chamado.")
return
}
setDescriptionError(null)
setLoading(true)
toast.loading("Criando ticket...", { id: "create-ticket" })
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,
subject: trimmedSubject,
summary: summary.trim() || undefined,
priority,
channel,
queueId,
requesterId: convexUserId as Id<"users">,
assigneeId: assigneeToSend,
categoryId: categoryId as Id<"ticketCategories">,
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
})
if (plainDescription.length > 0) {
await addComment({
ticketId: id as Id<"tickets">,
authorId: convexUserId as Id<"users">,
visibility: "PUBLIC",
body: sanitizedDescription,
attachments: [],
})
}
toast.success("Ticket criado!", { id: "create-ticket" })
router.replace(`/tickets/${id}`)
} catch (error) {
console.error(error)
toast.error("Não foi possível criar o ticket.", { id: "create-ticket" })
} finally {
setLoading(false)
}
}
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"
return (
<div className="mx-auto max-w-3xl px-6 py-8">
<Card className="rounded-2xl border border-slate-200 shadow-sm">
<CardHeader className="space-y-2">
<CardTitle className="text-2xl font-semibold text-neutral-900">Novo ticket</CardTitle>
<CardDescription className="text-sm text-neutral-600">Preencha as informações básicas para abrir um chamado.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-6">
<div className="space-y-2">
<label className="flex items-center gap-1 text-sm font-medium text-neutral-700" htmlFor="subject">
Assunto <span className="text-red-500">*</span>
</label>
<Input
id="subject"
value={subject}
onChange={(event) => {
setSubject(event.target.value)
if (subjectError) setSubjectError(null)
}}
placeholder="Ex.: Erro 500 no portal"
aria-invalid={subjectError ? "true" : undefined}
/>
{subjectError ? <p className="text-xs font-medium text-red-500">{subjectError}</p> : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700" htmlFor="summary">
Resumo
</label>
<textarea
id="summary"
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."
/>
</div>
<div className="space-y-2">
<label className="flex items-center gap-1 text-sm font-medium text-neutral-700">
Descrição <span className="text-red-500">*</span>
</label>
<RichTextEditor
value={description}
onChange={(html) => {
setDescription(html)
if (descriptionError) {
const plain = html.replace(/<[^>]*>/g, "").trim()
if (plain.length > 0) {
setDescriptionError(null)
}
}
}}
placeholder="Detalhe o problema, passos para reproduzir, links, etc."
/>
{descriptionError ? <p className="text-xs font-medium text-red-500">{descriptionError}</p> : null}
</div>
<div className="space-y-2">
<CategorySelectFields
tenantId={DEFAULT_TENANT_ID}
categoryId={categoryId}
subcategoryId={subcategoryId}
onCategoryChange={(value) => {
setCategoryId(value)
setCategoryError(null)
}}
onSubcategoryChange={(value) => {
setSubcategoryId(value)
setSubcategoryError(null)
}}
categoryLabel="Categoria primária *"
subcategoryLabel="Categoria secundária *"
/>
{categoryError || subcategoryError ? (
<div className="text-xs font-medium text-red-500">
{categoryError ? <div>{categoryError}</div> : null}
{subcategoryError ? <div>{subcategoryError}</div> : null}
</div>
) : null}
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<span className="text-sm font-medium text-neutral-700">Prioridade</span>
<Select value={priority} onValueChange={(value) => setPriority(value as TicketPriority)}>
<SelectTrigger className={cn(priorityTriggerClass, "w-full justify-between") }>
<SelectValue>
<Badge className={cn(priorityBadgeClass, priorityStyles[priority]?.badgeClass)}>
<PriorityIcon value={priority} />
{priorityStyles[priority]?.label ?? priority}
</Badge>
</SelectValue>
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => (
<SelectItem key={option} value={option} className={priorityItemClass}>
<span className="inline-flex items-center gap-2">
<PriorityIcon value={option} />
{priorityStyles[option].label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<span className="text-sm font-medium text-neutral-700">Canal</span>
<Select value={channel} onValueChange={setChannel}>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Canal" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
<SelectItem value="EMAIL" className={selectItemClass}>
E-mail
</SelectItem>
<SelectItem value="WHATSAPP" className={selectItemClass}>
WhatsApp
</SelectItem>
<SelectItem value="CHAT" className={selectItemClass}>
Chat
</SelectItem>
<SelectItem value="PHONE" className={selectItemClass}>
Telefone
</SelectItem>
<SelectItem value="API" className={selectItemClass}>
API
</SelectItem>
<SelectItem value="MANUAL" className={selectItemClass}>
Manual
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<span className="text-sm font-medium text-neutral-700">Fila</span>
<Select value={queueName ?? "NONE"} onValueChange={(value) => setQueueName(value === "NONE" ? null : value)}>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Sem fila" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
<SelectItem value="NONE" className={selectItemClass}>
Sem fila
</SelectItem>
{queueOptions.map((name) => (
<SelectItem key={name} value={name} className={selectItemClass}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</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 className="flex justify-end">
<Button
type="submit"
className="min-w-[120px] rounded-lg border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
disabled={loading}
>
{loading ? (
<>
<Spinner className="me-2" />
Criando...
</>
) : (
"Criar"
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

6
src/app/tickets/page.tsx Normal file
View file

@ -0,0 +1,6 @@
import { TicketsPageClient } from "./tickets-page-client"
export default function TicketsPage() {
return <TicketsPageClient />
}

View file

@ -0,0 +1,52 @@
"use client"
import dynamic from "next/dynamic"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
const TicketQueueSummaryCards = dynamic(
() =>
import("@/components/tickets/ticket-queue-summary").then((module) => ({
default: module.TicketQueueSummaryCards,
})),
{ ssr: false }
)
const TicketsView = dynamic(
() =>
import("@/components/tickets/tickets-view").then((module) => ({
default: module.TicketsView,
})),
{ ssr: false }
)
const NewTicketDialog = dynamic(
() =>
import("@/components/tickets/new-ticket-dialog").then((module) => ({
default: module.NewTicketDialog,
})),
{ ssr: false }
)
export function TicketsPageClient() {
return (
<AppShell
header={
<SiteHeader
title="Tickets"
lead="Visão consolidada de filas e SLAs"
secondaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>}
primaryAction={<NewTicketDialog />}
/>
}
>
<div className="flex flex-col gap-6">
<div className="px-4 lg:px-6">
<TicketQueueSummaryCards />
</div>
<TicketsView />
</div>
</AppShell>
)
}

View file

@ -0,0 +1,430 @@
"use client"
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"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
type AdminUser = {
id: string
email: string
name: string
role: RoleOption
tenantId: string
createdAt: string
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
}
function formatDate(dateIso: string) {
const date = new Date(dateIso)
return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "medium",
timeStyle: "short",
}).format(date)
}
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<AdminUser[]>(initialUsers)
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
const [email, setEmail] = useState("")
const [name, setName] = useState("")
const [role, setRole] = useState<RoleOption>("agent")
const [tenantId, setTenantId] = useState(defaultTenantId)
const [expiresInDays, setExpiresInDays] = useState<string>("7")
const [lastInviteLink, setLastInviteLink] = useState<string | null>(null)
const [revokingId, setRevokingId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!email || !email.includes("@")) {
toast.error("Informe um e-mail válido")
return
}
const payload = {
email,
name,
role,
tenantId,
expiresInDays: Number.parseInt(expiresInDays, 10),
}
startTransition(async () => {
try {
const response = await fetch("/api/admin/invites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Não foi possível gerar o convite")
}
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)
setExpiresInDays("7")
setLastInviteLink(nextInvite.inviteUrl)
toast.success("Convite criado com sucesso")
} catch (error) {
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 (
<Tabs defaultValue="invites" className="w-full">
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
<TabsTrigger value="queues" className="rounded-lg">Filas</TabsTrigger>
<TabsTrigger value="categories" className="rounded-lg">Categorias</TabsTrigger>
</TabsList>
<TabsContent value="invites" className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Gerar convite</CardTitle>
<CardDescription>
Envie convites personalizados com validade controlada e acompanhe o status em tempo real.
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={handleInviteSubmit}
className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_160px_160px_160px_auto]"
>
<div className="grid gap-2">
<Label htmlFor="invite-email">E-mail corporativo</Label>
<Input
id="invite-email"
type="email"
inputMode="email"
placeholder="nome@suaempresa.com"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-name">Nome</Label>
<Input
id="invite-name"
placeholder="Nome completo"
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<Label>Papel</Label>
<Select value={role} onValueChange={(value) => setRole(value as RoleOption)}>
<SelectTrigger id="invite-role">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{normalizedRoles.map((item) => (
<SelectItem key={item} value={item}>
{item === "customer"
? "Cliente"
: item === "admin"
? "Administrador"
: item === "manager"
? "Gestor"
: item === "agent"
? "Agente"
: "Colaborador"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-tenant">Tenant</Label>
<Input
id="invite-tenant"
value={tenantId}
onChange={(event) => setTenantId(event.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>Expira em</Label>
<Select value={expiresInDays} onValueChange={setExpiresInDays}>
<SelectTrigger id="invite-expiration">
<SelectValue placeholder="7 dias" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">7 dias</SelectItem>
<SelectItem value="14">14 dias</SelectItem>
<SelectItem value="30">30 dias</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? "Gerando..." : "Gerar convite"}
</Button>
</div>
</form>
{lastInviteLink ? (
<div className="mt-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="font-medium text-neutral-900">Link de convite pronto</p>
<p className="text-neutral-600">Compartilhe com o convidado. O link expira automaticamente no prazo definido.</p>
<p className="mt-2 truncate font-mono text-xs text-neutral-500">{lastInviteLink}</p>
</div>
<Button type="button" variant="outline" onClick={() => handleCopy(lastInviteLink)}>
Copiar link
</Button>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Convites emitidos</CardTitle>
<CardDescription>Histórico e status atual de todos os convites enviados para o workspace.</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
<th className="py-3 pr-4 font-medium">Colaborador</th>
<th className="py-3 pr-4 font-medium">Papel</th>
<th className="py-3 pr-4 font-medium">Tenant</th>
<th className="py-3 pr-4 font-medium">Expira em</th>
<th className="py-3 pr-4 font-medium">Status</th>
<th className="py-3 font-medium">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{invites.map((invite) => (
<tr key={invite.id} className="hover:bg-slate-50">
<td className="py-3 pr-4">
<div className="flex flex-col">
<span className="font-medium text-neutral-800">{invite.name || invite.email}</span>
<span className="text-xs text-neutral-500">{invite.email}</span>
</div>
</td>
<td className="py-3 pr-4 uppercase text-neutral-600">{invite.role}</td>
<td className="py-3 pr-4 text-neutral-600">{invite.tenantId}</td>
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
<td className="py-3 pr-4">
<Badge
variant={invite.status === "pending" ? "secondary" : invite.status === "accepted" ? "default" : invite.status === "expired" ? "outline" : "destructive"}
className="rounded-full px-3 py-1 text-[11px] uppercase tracking-wide"
>
{formatStatus(invite.status)}
</Badge>
</td>
<td className="py-3">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
Copiar link
</Button>
{invite.status === "pending" ? (
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
onClick={() => handleRevoke(invite.id)}
disabled={revokingId === invite.id}
>
{revokingId === invite.id ? "Revogando..." : "Revogar"}
</Button>
) : null}
</div>
</td>
</tr>
))}
{invites.length === 0 ? (
<tr>
<td colSpan={6} className="py-6 text-center text-neutral-500">
Nenhum convite emitido até o momento.
</td>
</tr>
) : null}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="users" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Equipe cadastrada</CardTitle>
<CardDescription>Usuários ativos e provisionados via convites aceitos.</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
<th className="py-3 pr-4 font-medium">Nome</th>
<th className="py-3 pr-4 font-medium">E-mail</th>
<th className="py-3 pr-4 font-medium">Papel</th>
<th className="py-3 pr-4 font-medium">Tenant</th>
<th className="py-3 font-medium">Criado em</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{users.map((user) => (
<tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
<td className="py-3 pr-4 uppercase text-neutral-600">{user.role}</td>
<td className="py-3 pr-4 text-neutral-600">{user.tenantId}</td>
<td className="py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
</tr>
))}
{users.length === 0 ? (
<tr>
<td colSpan={5} className="py-6 text-center text-neutral-500">
Nenhum usuário cadastrado até o momento.
</td>
</tr>
) : null}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="queues" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Gestão de filas</CardTitle>
<CardDescription>
Em breve será possível criar e reordenar as filas utilizadas na triagem dos tickets.
</CardDescription>
</CardHeader>
</Card>
</TabsContent>
<TabsContent value="categories" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Gestão de categorias</CardTitle>
<CardDescription>
Estamos preparando o painel completo para organizar categorias e subcategorias do catálogo.
</CardDescription>
</CardHeader>
</Card>
</TabsContent>
</Tabs>
)
}

View file

@ -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<T extends "category" | "subcategory"> =
| { 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<string[]>([])
const [deleteState, setDeleteState] = useState<DeleteState<"category" | "subcategory">>(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 (
<div className="space-y-6">
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="space-y-1">
<h3 className="text-lg font-semibold text-neutral-900">Categorias</h3>
<p className="text-sm text-neutral-600">
Organize a classificação primária e secundária utilizada nos tickets. Todas as alterações entram em vigor
imediatamente para novos atendimentos.
</p>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-3 rounded-xl border border-dashed border-slate-200 bg-white/80 p-4">
<div className="space-y-2">
<Label htmlFor="category-name">Nome da categoria</Label>
<Input
id="category-name"
value={categoryName}
onChange={(event) => setCategoryName(event.target.value)}
placeholder="Ex.: Incidentes"
disabled={isDisabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="category-description">Descrição (opcional)</Label>
<Textarea
id="category-description"
value={categoryDescription}
onChange={(event) => setCategoryDescription(event.target.value)}
placeholder="Contextualize quando usar esta categoria"
rows={3}
disabled={isDisabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subcategory-name">Subcategorias (opcional)</Label>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id="subcategory-name"
value={subcategoryDraft}
onChange={(event) => setSubcategoryDraft(event.target.value)}
placeholder="Ex.: Lentidão"
disabled={isDisabled}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
addSubcategory()
}
}}
/>
<Button
type="button"
variant="outline"
onClick={addSubcategory}
disabled={isDisabled || subcategoryDraft.trim().length < 2}
className="shrink-0"
>
Adicionar subcategoria
</Button>
</div>
{subcategoryList.length ? (
<div className="flex flex-wrap gap-2">
{subcategoryList.map((item) => (
<div
key={item}
className="group inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-3 py-1 text-xs text-neutral-700"
>
<span>{item}</span>
<button
type="button"
onClick={() => removeSubcategory(item)}
className="rounded-full p-1 text-neutral-500 transition hover:bg-white hover:text-neutral-900"
disabled={isDisabled}
>
×
</button>
</div>
))}
</div>
) : null}
</div>
</div>
<Button
onClick={handleCreateCategory}
disabled={isDisabled || isCreatingCategory}
className="w-full sm:w-auto"
>
Adicionar categoria
</Button>
</div>
<div className="space-y-3 rounded-xl border border-slate-200 bg-slate-50/60 p-4 text-sm text-neutral-600">
<p className="font-medium text-neutral-800">Boas práticas</p>
<ul className="list-disc space-y-1 pl-4">
<li>Mantenha nomes concisos e fáceis de entender.</li>
<li>Use a descrição para orientar a equipe sobre quando aplicar cada categoria.</li>
<li>Subcategorias devem ser específicas e mutuamente exclusivas.</li>
</ul>
</div>
</div>
</section>
<div className="space-y-4">
{categories?.length ? (
categories.map((category) => (
<CategoryItem
key={category.id}
category={category}
onUpdate={handleUpdateCategory}
onDelete={() => setDeleteState({ type: "category", targetId: category.id, reason: "" })}
onCreateSubcategory={handleCreateSubcategory}
onUpdateSubcategory={handleUpdateSubcategory}
onDeleteSubcategory={(subcategoryId) =>
setDeleteState({ type: "subcategory", targetId: subcategoryId, reason: "" })
}
disabled={isDisabled}
/>
))
) : (
<div className="rounded-xl border border-dashed border-slate-200 bg-white/60 p-6 text-center text-sm text-neutral-600">
Nenhuma categoria cadastrada ainda.
</div>
)}
</div>
<Dialog
open={Boolean(pendingDelete)}
onOpenChange={(open) => {
if (!open) setDeleteState(null)
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Confirmar remoção</DialogTitle>
<DialogDescription>
A remoção é permanente. Certifique-se de que não tickets em aberto associados.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 pt-2">
<Label htmlFor="delete-reason">Motivo (opcional)</Label>
<Textarea
id="delete-reason"
rows={3}
placeholder="Descreva o motivo da remoção"
value={pendingDelete?.reason ?? ""}
onChange={(event) =>
setDeleteState((current) =>
current ? { ...current, reason: event.target.value } : current
)
}
/>
<p className="text-xs text-neutral-500">
Caso existam tickets vinculados, será necessário mover para outra categoria antes de continuar.
</p>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setDeleteState(null)}>
Cancelar
</Button>
<Button
variant="destructive"
onClick={async () => {
const target = pendingDelete
if (!target) return
if (target.type === "category") {
await handleDeleteCategory(target.targetId)
} else {
await handleDeleteSubcategory(target.targetId)
}
setDeleteState(null)
}}
>
Remover
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
interface CategoryItemProps {
category: TicketCategory
disabled?: boolean
onUpdate: (category: TicketCategory, next: { name: string; description: string }) => Promise<void>
onDelete: () => void
onCreateSubcategory: (categoryId: string, payload: { name: string }) => Promise<void>
onUpdateSubcategory: (subcategory: TicketSubcategory, name: string) => Promise<void>
onDeleteSubcategory: (subcategoryId: string) => void
}
function CategoryItem({
category,
disabled,
onUpdate,
onDelete,
onCreateSubcategory,
onUpdateSubcategory,
onDeleteSubcategory,
}: CategoryItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [name, setName] = useState(category.name)
const [description, setDescription] = useState(category.description ?? "")
const [subcategoryDraft, setSubcategoryDraft] = useState("")
const hasSubcategories = category.secondary.length > 0
async function handleSave() {
await onUpdate(category, { name, description })
setIsEditing(false)
}
return (
<div className="space-y-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
{isEditing ? (
<div className="flex flex-col gap-2">
<Input value={name} onChange={(event) => setName(event.target.value)} disabled={disabled} />
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={2}
placeholder="Descrição"
disabled={disabled}
/>
</div>
) : (
<>
<h4 className="text-base font-semibold text-neutral-900">{category.name}</h4>
{category.description ? (
<p className="text-sm text-neutral-600">{category.description}</p>
) : null}
</>
)}
</div>
<div className="flex items-center gap-2">
{hasSubcategories ? (
<Badge variant="outline" className="rounded-full border-slate-200 px-3 py-1 text-xs text-neutral-600">
{category.secondary.length} subcategorias
</Badge>
) : null}
{isEditing ? (
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleSave} disabled={disabled}>
Salvar
</Button>
<Button size="sm" variant="outline" onClick={() => setIsEditing(false)}>
Cancelar
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)} disabled={disabled}>
Editar
</Button>
<Button size="sm" variant="destructive" onClick={onDelete} disabled={disabled}>
Remover
</Button>
</div>
)}
</div>
</div>
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Subcategorias</p>
<div className="space-y-2">
{category.secondary.length ? (
category.secondary.map((subcategory) => (
<SubcategoryItem
key={subcategory.id}
subcategory={subcategory}
disabled={disabled}
onUpdate={(value) => onUpdateSubcategory(subcategory, value)}
onDelete={() => onDeleteSubcategory(subcategory.id)}
/>
))
) : (
<div className="rounded-lg border border-dashed border-slate-200 bg-slate-50/60 p-3 text-sm text-neutral-600">
Nenhuma subcategoria cadastrada.
</div>
)}
</div>
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-slate-200 bg-white p-3">
<Label className="text-xs uppercase tracking-wide text-neutral-500">Nova subcategoria</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={subcategoryDraft}
onChange={(event) => setSubcategoryDraft(event.target.value)}
placeholder="Ex.: Configuração"
disabled={disabled}
/>
<Button
size="sm"
className="sm:w-auto"
onClick={async () => {
if (!subcategoryDraft.trim()) return
await onCreateSubcategory(category.id, { name: subcategoryDraft })
setSubcategoryDraft("")
}}
disabled={disabled || subcategoryDraft.trim().length < 2}
>
Adicionar
</Button>
</div>
</div>
</div>
</div>
)
}
interface SubcategoryItemProps {
subcategory: TicketSubcategory
disabled?: boolean
onUpdate: (nextValue: string) => Promise<void>
onDelete: () => void
}
function SubcategoryItem({ subcategory, disabled, onUpdate, onDelete }: SubcategoryItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [name, setName] = useState(subcategory.name)
async function handleSave() {
await onUpdate(name)
setIsEditing(false)
}
return (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 shadow-sm">
{isEditing ? (
<Input value={name} onChange={(event) => setName(event.target.value)} disabled={disabled} className="max-w-sm" />
) : (
<span className="text-sm font-medium text-neutral-800">{subcategory.name}</span>
)}
<div className="flex items-center gap-2">
{isEditing ? (
<>
<Button size="sm" onClick={handleSave} disabled={disabled}>
Salvar
</Button>
<Button size="sm" variant="outline" onClick={() => setIsEditing(false)}>
Cancelar
</Button>
</>
) : (
<>
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)} disabled={disabled}>
Renomear
</Button>
<Button size="sm" variant="destructive" onClick={onDelete} disabled={disabled}>
Remover
</Button>
</>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,551 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconAdjustments, IconForms, IconListDetails, IconTypography } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
type FieldOption = { value: string; label: string }
type Field = {
id: string
key: string
label: string
description: string
type: "text" | "number" | "select" | "date" | "boolean"
required: boolean
options: FieldOption[]
order: number
}
const TYPE_LABELS: Record<Field["type"], string> = {
text: "Texto",
number: "Número",
select: "Seleção",
date: "Data",
boolean: "Verdadeiro/Falso",
}
export function FieldsManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const fields = useQuery(
api.fields.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Field[] | undefined
const createField = useMutation(api.fields.create)
const updateField = useMutation(api.fields.update)
const removeField = useMutation(api.fields.remove)
const reorderFields = useMutation(api.fields.reorder)
const [label, setLabel] = useState("")
const [description, setDescription] = useState("")
const [type, setType] = useState<Field["type"]>("text")
const [required, setRequired] = useState(false)
const [options, setOptions] = useState<FieldOption[]>([])
const [saving, setSaving] = useState(false)
const [editingField, setEditingField] = useState<Field | null>(null)
const totals = useMemo(() => {
if (!fields) return { total: 0, required: 0, select: 0 }
return {
total: fields.length,
required: fields.filter((field) => field.required).length,
select: fields.filter((field) => field.type === "select").length,
}
}, [fields])
const resetForm = () => {
setLabel("")
setDescription("")
setType("text")
setRequired(false)
setOptions([])
}
const normalizeOptions = (source: FieldOption[]) =>
source
.map((option) => ({
label: option.label.trim(),
value: option.value.trim() || option.label.trim().toLowerCase().replace(/\s+/g, "_"),
}))
.filter((option) => option.label.length > 0)
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!label.trim()) {
toast.error("Informe o rótulo do campo")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
setSaving(true)
toast.loading("Criando campo...", { id: "field" })
try {
await createField({
tenantId,
actorId: convexUserId as Id<"users">,
label: label.trim(),
description: description.trim() || undefined,
type,
required,
options: preparedOptions,
})
toast.success("Campo criado", { id: "field" })
resetForm()
} catch (error) {
console.error(error)
toast.error("Não foi possível criar o campo", { id: "field" })
} finally {
setSaving(false)
}
}
const handleRemove = async (field: Field) => {
const confirmed = window.confirm(`Excluir o campo ${field.label}?`)
if (!confirmed) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
toast.loading("Removendo campo...", { id: `field-remove-${field.id}` })
try {
await removeField({
tenantId,
fieldId: field.id as Id<"ticketFields">,
actorId: convexUserId as Id<"users">,
})
toast.success("Campo removido", { id: `field-remove-${field.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover o campo", { id: `field-remove-${field.id}` })
}
}
const openEdit = (field: Field) => {
setEditingField(field)
setLabel(field.label)
setDescription(field.description)
setType(field.type)
setRequired(field.required)
setOptions(field.options)
}
const handleUpdate = async () => {
if (!editingField) return
if (!label.trim()) {
toast.error("Informe o rótulo do campo")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
setSaving(true)
toast.loading("Atualizando campo...", { id: "field-edit" })
try {
await updateField({
tenantId,
fieldId: editingField.id as Id<"ticketFields">,
actorId: convexUserId as Id<"users">,
label: label.trim(),
description: description.trim() || undefined,
type,
required,
options: preparedOptions,
})
toast.success("Campo atualizado", { id: "field-edit" })
setEditingField(null)
resetForm()
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o campo", { id: "field-edit" })
} finally {
setSaving(false)
}
}
const moveField = async (field: Field, direction: "up" | "down") => {
if (!fields) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
const index = fields.findIndex((item) => item.id === field.id)
const targetIndex = direction === "up" ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= fields.length) return
const reordered = [...fields]
const [removed] = reordered.splice(index, 1)
reordered.splice(targetIndex, 0, removed)
try {
await reorderFields({
tenantId,
actorId: convexUserId as Id<"users">,
orderedIds: reordered.map((item) => item.id as Id<"ticketFields">),
})
toast.success("Ordem atualizada")
} catch (error) {
console.error(error)
toast.error("Não foi possível reordenar os campos")
}
}
const addOption = () => {
setOptions((current) => [...current, { label: "", value: "" }])
}
const updateOption = (index: number, key: keyof FieldOption, value: string) => {
setOptions((current) => {
const copy = [...current]
copy[index] = { ...copy[index], [key]: value }
return copy
})
}
const removeOption = (index: number) => {
setOptions((current) => current.filter((_, optIndex) => optIndex !== index))
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconForms className="size-4" /> Campos personalizados
</CardTitle>
<CardDescription>Metadados adicionais disponíveis nos tickets.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{fields ? totals.total : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconTypography className="size-4" /> Campos obrigatórios
</CardTitle>
<CardDescription>Informações exigidas na abertura.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{fields ? totals.required : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconListDetails className="size-4" /> Campos de seleção
</CardTitle>
<CardDescription>Usados para listas e múltipla escolha.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{fields ? totals.select : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<IconAdjustments className="size-5 text-neutral-500" /> Novo campo
</CardTitle>
<CardDescription>Capture informações específicas do seu fluxo de atendimento.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="grid gap-4 lg:grid-cols-[minmax(0,280px)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="field-label">Rótulo</Label>
<Input
id="field-label"
placeholder="Ex.: Número do contrato"
value={label}
onChange={(event) => setLabel(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Tipo de dado</Label>
<Select value={type} onValueChange={(value) => setType(value as Field["type"])}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Texto curto</SelectItem>
<SelectItem value="number">Número</SelectItem>
<SelectItem value="select">Seleção</SelectItem>
<SelectItem value="date">Data</SelectItem>
<SelectItem value="boolean">Verdadeiro/Falso</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox id="field-required" checked={required} onCheckedChange={(value) => setRequired(Boolean(value))} />
<Label htmlFor="field-required" className="text-sm font-normal text-neutral-600">
Campo obrigatório na abertura
</Label>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="field-description">Descrição</Label>
<textarea
id="field-description"
className="min-h-[96px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
placeholder="Como este campo será utilizado"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
{type === "select" ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Opções</Label>
<Button type="button" variant="outline" size="sm" onClick={addOption}>
Adicionar opção
</Button>
</div>
{options.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
Adicione pelo menos uma opção para este campo de seleção.
</p>
) : (
<div className="space-y-3">
{options.map((option, index) => (
<div key={index} className="grid gap-3 rounded-lg border border-slate-200 p-3 md:grid-cols-[minmax(0,1fr)_minmax(0,200px)_auto]">
<Input
placeholder="Rótulo"
value={option.label}
onChange={(event) => updateOption(index, "label", event.target.value)}
/>
<Input
placeholder="Valor"
value={option.value}
onChange={(event) => updateOption(index, "value", event.target.value)}
/>
<Button variant="ghost" type="button" onClick={() => removeOption(index)}>
Remover
</Button>
</div>
))}
</div>
)}
</div>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
Criar campo
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
<div className="space-y-4">
{fields === undefined ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-28 rounded-2xl" />
))}
</div>
) : fields.length === 0 ? (
<Card className="border-dashed border-slate-300 bg-slate-50/80">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhum campo cadastrado</CardTitle>
<CardDescription className="text-neutral-600">
Crie campos personalizados para enriquecer os tickets com informações importantes.
</CardDescription>
</CardHeader>
</Card>
) : (
fields.map((field, index) => (
<Card key={field.id} className="border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-3">
<CardTitle className="text-xl font-semibold text-neutral-900">{field.label}</CardTitle>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{TYPE_LABELS[field.type]}
</Badge>
{field.required ? (
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
obrigatório
</Badge>
) : null}
</div>
<CardDescription className="text-neutral-600">Identificador: {field.key}</CardDescription>
{field.description ? (
<p className="text-sm text-neutral-600">{field.description}</p>
) : null}
</div>
<div className="flex flex-col items-end gap-2">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(field)}>
Editar
</Button>
<Button variant="destructive" size="sm" onClick={() => handleRemove(field)}>
Excluir
</Button>
</div>
<div className="flex gap-2 text-xs text-neutral-500">
<Button
type="button"
size="sm"
variant="ghost"
className="px-2"
disabled={index === 0}
onClick={() => moveField(field, "up")}
>
Subir
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="px-2"
disabled={index === fields.length - 1}
onClick={() => moveField(field, "down")}
>
Descer
</Button>
</div>
</div>
</div>
</CardHeader>
{field.type === "select" && field.options.length > 0 ? (
<CardContent>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">Opções cadastradas</p>
<div className="flex flex-wrap gap-2">
{field.options.map((option) => (
<Badge key={option.value} variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{option.label} ({option.value})
</Badge>
))}
</div>
</CardContent>
) : null}
</Card>
))
)}
</div>
<Dialog open={Boolean(editingField)} onOpenChange={(value) => (!value ? setEditingField(null) : null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Editar campo</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2 lg:grid-cols-[minmax(0,260px)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="edit-field-label">Rótulo</Label>
<Input
id="edit-field-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Tipo de dado</Label>
<Select value={type} onValueChange={(value) => setType(value as Field["type"])}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Texto curto</SelectItem>
<SelectItem value="number">Número</SelectItem>
<SelectItem value="select">Seleção</SelectItem>
<SelectItem value="date">Data</SelectItem>
<SelectItem value="boolean">Verdadeiro/Falso</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox id="edit-field-required" checked={required} onCheckedChange={(value) => setRequired(Boolean(value))} />
<Label htmlFor="edit-field-required" className="text-sm font-normal text-neutral-600">
Campo obrigatório na abertura
</Label>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-field-description">Descrição</Label>
<textarea
id="edit-field-description"
className="min-h-[96px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
{type === "select" ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Opções</Label>
<Button type="button" variant="outline" size="sm" onClick={addOption}>
Adicionar opção
</Button>
</div>
{options.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
Inclua ao menos uma opção para salvar este campo.
</p>
) : (
<div className="space-y-3">
{options.map((option, index) => (
<div key={index} className="grid gap-3 rounded-lg border border-slate-200 p-3 md:grid-cols-[minmax(0,1fr)_minmax(0,200px)_auto]">
<Input
placeholder="Rótulo"
value={option.label}
onChange={(event) => updateOption(index, "label", event.target.value)}
/>
<Input
placeholder="Valor"
value={option.value}
onChange={(event) => updateOption(index, "value", event.target.value)}
/>
<Button variant="ghost" type="button" onClick={() => removeOption(index)}>
Remover
</Button>
</div>
))}
</div>
)}
</div>
) : null}
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setEditingField(null)}>
Cancelar
</Button>
<Button onClick={handleUpdate} disabled={saving}>
Salvar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,332 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconInbox, IconHierarchy2, IconLink, IconPlus } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { useDefaultQueues } from "@/hooks/use-default-queues"
type Queue = {
id: string
name: string
slug: string
team: { id: string; name: string } | null
}
type TeamOption = {
id: string
name: string
}
export function QueuesManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
useDefaultQueues(tenantId)
const NO_TEAM_VALUE = "__none__"
const queues = useQuery(
api.queues.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Queue[] | undefined
const teams = useQuery(
api.teams.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as TeamOption[] | undefined
const createQueue = useMutation(api.queues.create)
const updateQueue = useMutation(api.queues.update)
const removeQueue = useMutation(api.queues.remove)
const [name, setName] = useState("")
const [teamId, setTeamId] = useState<string | undefined>()
const [editingQueue, setEditingQueue] = useState<Queue | null>(null)
const [saving, setSaving] = useState(false)
const totalQueues = queues?.length ?? 0
const withoutTeam = useMemo(() => {
if (!queues) return 0
return queues.filter((queue) => !queue.team).length
}, [queues])
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!name.trim()) {
toast.error("Informe o nome da fila")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Criando fila...", { id: "queue" })
try {
await createQueue({
tenantId,
actorId: convexUserId as Id<"users">,
name: name.trim(),
teamId: teamId as Id<"teams"> | undefined,
})
setName("")
setTeamId(undefined)
toast.success("Fila criada", { id: "queue" })
} catch (error) {
console.error(error)
toast.error("Não foi possível criar a fila", { id: "queue" })
} finally {
setSaving(false)
}
}
const openEdit = (queue: Queue) => {
setEditingQueue(queue)
setName(queue.name)
setTeamId(queue.team?.id)
}
const handleUpdate = async () => {
if (!editingQueue) return
if (!name.trim()) {
toast.error("Informe o nome da fila")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Salvando alterações...", { id: "queue-edit" })
try {
await updateQueue({
queueId: editingQueue.id as Id<"queues">,
tenantId,
actorId: convexUserId as Id<"users">,
name: name.trim(),
teamId: (teamId ?? undefined) as Id<"teams"> | undefined,
})
toast.success("Fila atualizada", { id: "queue-edit" })
setEditingQueue(null)
setName("")
setTeamId(undefined)
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar a fila", { id: "queue-edit" })
} finally {
setSaving(false)
}
}
const handleRemove = async (queue: Queue) => {
const confirmed = window.confirm(`Remover a fila ${queue.name}?`)
if (!confirmed) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
toast.loading("Removendo fila...", { id: `queue-remove-${queue.id}` })
try {
await removeQueue({ tenantId, queueId: queue.id as Id<"queues">, actorId: convexUserId as Id<"users"> })
toast.success("Fila removida", { id: `queue-remove-${queue.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover a fila", { id: `queue-remove-${queue.id}` })
}
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconInbox className="size-4" /> Filas criadas
</CardTitle>
<CardDescription>Rotas que recebem tickets dos canais conectados.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{queues ? totalQueues : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconHierarchy2 className="size-4" /> Com time definido
</CardTitle>
<CardDescription>Filas com time responsável atribuído.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{queues ? totalQueues - withoutTeam : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconLink className="size-4" /> Sem vinculação
</CardTitle>
<CardDescription>Filas aguardando responsáveis.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{queues ? withoutTeam : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<IconPlus className="size-5 text-neutral-500" /> Nova fila
</CardTitle>
<CardDescription>Defina as filas de atendimento, conectando-as aos times responsáveis.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="grid gap-4 md:grid-cols-[minmax(0,300px)_minmax(0,300px)_auto]">
<div className="space-y-2">
<Label htmlFor="queue-name">Nome da fila</Label>
<Input
id="queue-name"
placeholder="Ex.: Suporte N1"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Time responsável</Label>
<Select
value={teamId ?? NO_TEAM_VALUE}
onValueChange={(value) => setTeamId(value === NO_TEAM_VALUE ? undefined : value)}
>
<SelectTrigger>
<SelectValue placeholder="Selecione um time" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_TEAM_VALUE}>Sem time</SelectItem>
{teams?.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button type="submit" className="w-full" disabled={saving}>
Criar fila
</Button>
</div>
</form>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
{queues === undefined ? (
Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-40 rounded-2xl" />)
) : queues.length === 0 ? (
<Card className="border-dashed border-slate-300 bg-slate-50/80">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhuma fila cadastrada</CardTitle>
<CardDescription className="text-neutral-600">
Crie filas para segmentar os atendimentos por canal ou especialidade.
</CardDescription>
</CardHeader>
</Card>
) : (
queues.map((queue) => (
<Card key={queue.id} className="border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-xl font-semibold text-neutral-900">{queue.name}</CardTitle>
<CardDescription className="mt-2 text-xs uppercase tracking-wide text-neutral-500">
Slug: {queue.slug}
</CardDescription>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(queue)}>
Editar
</Button>
<Button variant="destructive" size="sm" onClick={() => handleRemove(queue)}>
Excluir
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3 text-sm text-neutral-600">
<span className="font-medium text-neutral-500">Time:</span>
{queue.team ? (
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-700">
{queue.team.name}
</Badge>
) : (
<span className="text-neutral-500">Sem time vinculado</span>
)}
</div>
</CardContent>
</Card>
))
)}
</div>
<Dialog open={Boolean(editingQueue)} onOpenChange={(value) => (!value ? setEditingQueue(null) : null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Editar fila</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="edit-queue-name">Nome</Label>
<Input
id="edit-queue-name"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Time responsável</Label>
<Select
value={teamId ?? NO_TEAM_VALUE}
onValueChange={(value) => setTeamId(value === NO_TEAM_VALUE ? undefined : value)}
>
<SelectTrigger>
<SelectValue placeholder="Selecione um time" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_TEAM_VALUE}>Sem time</SelectItem>
{teams?.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setEditingQueue(null)}>
Cancelar
</Button>
<Button onClick={handleUpdate} disabled={saving}>
Salvar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,390 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconAlarm, IconBolt, IconTargetArrow } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
type SlaPolicy = {
id: string
name: string
description: string
timeToFirstResponse: number | null
timeToResolution: number | null
}
function formatMinutes(value: number | null) {
if (value === null) return "—"
if (value < 60) return `${Math.round(value)} min`
const hours = Math.floor(value / 60)
const minutes = Math.round(value % 60)
if (minutes === 0) return `${hours}h`
return `${hours}h ${minutes}min`
}
export function SlasManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const slas = useQuery(
api.slas.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as SlaPolicy[] | undefined
const createSla = useMutation(api.slas.create)
const updateSla = useMutation(api.slas.update)
const removeSla = useMutation(api.slas.remove)
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [firstResponse, setFirstResponse] = useState<string>("")
const [resolution, setResolution] = useState<string>("")
const [saving, setSaving] = useState(false)
const [editingSla, setEditingSla] = useState<SlaPolicy | null>(null)
const { bestFirstResponse, bestResolution } = useMemo(() => {
if (!slas) return { bestFirstResponse: null, bestResolution: null }
const response = slas.reduce<number | null>((acc, sla) => {
if (sla.timeToFirstResponse === null) return acc
return acc === null ? sla.timeToFirstResponse : Math.min(acc, sla.timeToFirstResponse)
}, null)
const resolution = slas.reduce<number | null>((acc, sla) => {
if (sla.timeToResolution === null) return acc
return acc === null ? sla.timeToResolution : Math.min(acc, sla.timeToResolution)
}, null)
return { bestFirstResponse: response, bestResolution: resolution }
}, [slas])
const resetForm = () => {
setName("")
setDescription("")
setFirstResponse("")
setResolution("")
}
const parseNumber = (value: string) => {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
}
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!name.trim()) {
toast.error("Informe um nome para a política")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Criando SLA...", { id: "sla" })
try {
await createSla({
tenantId,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
timeToFirstResponse: parseNumber(firstResponse),
timeToResolution: parseNumber(resolution),
})
toast.success("Política criada", { id: "sla" })
resetForm()
} catch (error) {
console.error(error)
toast.error("Não foi possível criar a política", { id: "sla" })
} finally {
setSaving(false)
}
}
const openEdit = (policy: SlaPolicy) => {
setEditingSla(policy)
setName(policy.name)
setDescription(policy.description)
setFirstResponse(policy.timeToFirstResponse ? String(policy.timeToFirstResponse) : "")
setResolution(policy.timeToResolution ? String(policy.timeToResolution) : "")
}
const handleUpdate = async () => {
if (!editingSla) return
if (!name.trim()) {
toast.error("Informe um nome para a política")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Salvando alterações...", { id: "sla-edit" })
try {
await updateSla({
tenantId,
policyId: editingSla.id as Id<"slaPolicies">,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
timeToFirstResponse: parseNumber(firstResponse),
timeToResolution: parseNumber(resolution),
})
toast.success("Política atualizada", { id: "sla-edit" })
setEditingSla(null)
resetForm()
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar a política", { id: "sla-edit" })
} finally {
setSaving(false)
}
}
const handleRemove = async (policy: SlaPolicy) => {
const confirmed = window.confirm(`Excluir a política ${policy.name}?`)
if (!confirmed) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
toast.loading("Removendo política...", { id: `sla-remove-${policy.id}` })
try {
await removeSla({
tenantId,
policyId: policy.id as Id<"slaPolicies">,
actorId: convexUserId as Id<"users">,
})
toast.success("Política removida", { id: `sla-remove-${policy.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover a política", { id: `sla-remove-${policy.id}` })
}
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconTargetArrow className="size-4" /> Políticas criadas
</CardTitle>
<CardDescription>Regras aplicadas às filas e tickets.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{slas ? slas.length : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconAlarm className="size-4" /> Resposta (média)
</CardTitle>
<CardDescription>Tempo mínimo para primeira resposta.</CardDescription>
</CardHeader>
<CardContent className="text-xl font-semibold text-neutral-900">
{slas ? formatMinutes(bestFirstResponse ?? null) : <Skeleton className="h-8 w-24" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconBolt className="size-4" /> Resolução (média)
</CardTitle>
<CardDescription>Alvo para encerrar chamados.</CardDescription>
</CardHeader>
<CardContent className="text-xl font-semibold text-neutral-900">
{slas ? formatMinutes(bestResolution ?? null) : <Skeleton className="h-8 w-24" />}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nova política de SLA</CardTitle>
<CardDescription>Defina metas de resposta e resolução para garantir previsibilidade no atendimento.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="grid gap-4 md:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="sla-name">Nome da política</Label>
<Input
id="sla-name"
placeholder="Ex.: Resposta prioritária"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="sla-first-response">Primeira resposta (minutos)</Label>
<Input
id="sla-first-response"
type="number"
min={1}
value={firstResponse}
onChange={(event) => setFirstResponse(event.target.value)}
placeholder="Opcional"
/>
</div>
<div className="space-y-2">
<Label htmlFor="sla-resolution">Resolução (minutos)</Label>
<Input
id="sla-resolution"
type="number"
min={1}
value={resolution}
onChange={(event) => setResolution(event.target.value)}
placeholder="Opcional"
/>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="sla-description">Descrição</Label>
<textarea
id="sla-description"
className="min-h-[120px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
placeholder="Como esta política será aplicada"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
Criar política
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
<div className="space-y-4">
{slas === undefined ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-32 rounded-2xl" />
))}
</div>
) : slas.length === 0 ? (
<Card className="border-dashed border-slate-300 bg-slate-50/80">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhuma política cadastrada</CardTitle>
<CardDescription className="text-neutral-600">
Crie SLAs para monitorar o tempo de resposta e resolução dos seus chamados.
</CardDescription>
</CardHeader>
</Card>
) : (
slas.map((policy) => (
<Card key={policy.id} className="border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<CardTitle className="text-xl font-semibold text-neutral-900">{policy.name}</CardTitle>
{policy.description ? (
<CardDescription className="text-neutral-600">{policy.description}</CardDescription>
) : null}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(policy)}>
Editar
</Button>
<Button variant="destructive" size="sm" onClick={() => handleRemove(policy)}>
Excluir
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<dl className="grid gap-4 md:grid-cols-2">
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Primeira resposta</dt>
<dd className="text-lg font-semibold text-neutral-900">{formatMinutes(policy.timeToFirstResponse)}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Resolução</dt>
<dd className="text-lg font-semibold text-neutral-900">{formatMinutes(policy.timeToResolution)}</dd>
</div>
</dl>
</CardContent>
</Card>
))
)}
</div>
<Dialog open={Boolean(editingSla)} onOpenChange={(value) => (!value ? setEditingSla(null) : null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Editar política de SLA</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="edit-sla-name">Nome</Label>
<Input
id="edit-sla-name"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="edit-sla-first">Primeira resposta (minutos)</Label>
<Input
id="edit-sla-first"
type="number"
min={1}
value={firstResponse}
onChange={(event) => setFirstResponse(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-sla-resolution">Resolução (minutos)</Label>
<Input
id="edit-sla-resolution"
type="number"
min={1}
value={resolution}
onChange={(event) => setResolution(event.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="edit-sla-description">Descrição</Label>
<textarea
id="edit-sla-description"
className="min-h-[120px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setEditingSla(null)}>
Cancelar
</Button>
<Button onClick={handleUpdate} disabled={saving}>
Salvar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,428 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconUsersGroup, IconCalendarClock, IconSettings, IconUserPlus } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Checkbox } from "@/components/ui/checkbox"
import { Skeleton } from "@/components/ui/skeleton"
type Team = {
id: string
name: string
description: string
members: { id: string; name: string; email: string; role: string }[]
queueCount: number
createdAt: number
}
type DirectoryUser = {
id: string
name: string
email: string
role: string
teams: string[]
}
export function TeamsManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const teams = useQuery(
api.teams.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Team[] | undefined
const directory = useQuery(
api.teams.directory,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as DirectoryUser[] | undefined
const createTeam = useMutation(api.teams.create)
const updateTeam = useMutation(api.teams.update)
const removeTeam = useMutation(api.teams.remove)
const setMembers = useMutation(api.teams.setMembers)
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [editingTeam, setEditingTeam] = useState<Team | null>(null)
const [membershipTeam, setMembershipTeam] = useState<Team | null>(null)
const [selectedMembers, setSelectedMembers] = useState<Set<string>>(new Set())
const [saving, setSaving] = useState(false)
const totalMembers = useMemo(() => {
if (!teams) return 0
return teams.reduce((acc, team) => acc + team.members.length, 0)
}, [teams])
const totalQueues = useMemo(() => {
if (!teams) return 0
return teams.reduce((acc, team) => acc + team.queueCount, 0)
}, [teams])
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!name.trim()) {
toast.error("Informe um nome para o time")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Criando time...", { id: "team" })
try {
await createTeam({
tenantId,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
})
setName("")
setDescription("")
toast.success("Time criado com sucesso", { id: "team" })
} catch (error) {
console.error(error)
toast.error("Não foi possível criar o time", { id: "team" })
} finally {
setSaving(false)
}
}
const openEdit = (team: Team) => {
setEditingTeam(team)
setName(team.name)
setDescription(team.description)
}
const handleUpdate = async () => {
if (!editingTeam) return
if (!name.trim()) {
toast.error("Informe um nome para o time")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Salvando alterações...", { id: "team-edit" })
try {
await updateTeam({
tenantId,
teamId: editingTeam.id as Id<"teams">,
actorId: convexUserId as Id<"users">,
name: name.trim(),
description: description.trim() || undefined,
})
toast.success("Time atualizado", { id: "team-edit" })
setEditingTeam(null)
setName("")
setDescription("")
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o time", { id: "team-edit" })
} finally {
setSaving(false)
}
}
const handleRemove = async (team: Team) => {
const confirmed = window.confirm(`Excluir o time ${team.name}?`)
if (!confirmed) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
toast.loading("Removendo time...", { id: `team-remove-${team.id}` })
try {
await removeTeam({ tenantId, teamId: team.id as Id<"teams">, actorId: convexUserId as Id<"users"> })
toast.success("Time removido", { id: `team-remove-${team.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover o time", { id: `team-remove-${team.id}` })
}
}
const openMembership = (team: Team) => {
setMembershipTeam(team)
setSelectedMembers(new Set(team.members.map((member) => member.id)))
}
const toggleMember = (userId: string) => {
setSelectedMembers((current) => {
const next = new Set(current)
if (next.has(userId)) {
next.delete(userId)
} else {
next.add(userId)
}
return next
})
}
const handleMembershipSave = async () => {
if (!membershipTeam) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
setSaving(true)
toast.loading("Atualizando membros...", { id: "team-members" })
try {
await setMembers({
tenantId,
teamId: membershipTeam.id as Id<"teams">,
actorId: convexUserId as Id<"users">,
memberIds: Array.from(selectedMembers) as Id<"users">[],
})
toast.success("Membros atualizados", { id: "team-members" })
setMembershipTeam(null)
setSelectedMembers(new Set())
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar os membros", { id: "team-members" })
} finally {
setSaving(false)
}
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconUsersGroup className="size-4" /> Times cadastrados
</CardTitle>
<CardDescription>Organize a operação em células de atendimento.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{teams ? teams.length : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconUserPlus className="size-4" /> Pessoas alocadas
</CardTitle>
<CardDescription>Soma de integrantes em cada time.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{teams ? totalMembers : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconCalendarClock className="size-4" /> Filas vinculadas
</CardTitle>
<CardDescription>Total de canais ligados aos times.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{teams ? totalQueues : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<IconSettings className="size-5 text-neutral-500" /> Novo time
</CardTitle>
<CardDescription>Defina a que squad, célula ou capítulo cada chamado poderá ser roteado.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="grid gap-4 md:grid-cols-[minmax(0,300px)_minmax(0,1fr)_auto]">
<div className="space-y-2">
<Label htmlFor="team-name">Nome do time</Label>
<Input
id="team-name"
placeholder="Ex.: Suporte N1"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="team-description">Descrição</Label>
<Input
id="team-description"
placeholder="Contextualize a responsabilidade do time"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="flex items-end">
<Button type="submit" className="w-full" disabled={saving}>
{saving && !editingTeam ? "Salvando..." : "Adicionar"}
</Button>
</div>
</form>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
{teams === undefined ? (
Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} className="h-48 rounded-2xl" />)
) : teams.length === 0 ? (
<Card className="border-dashed border-slate-300 bg-slate-50/80">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhum time cadastrado</CardTitle>
<CardDescription className="text-neutral-600">
Crie o primeiro time para organizar a distribuição das filas e agentes.
</CardDescription>
</CardHeader>
</Card>
) : (
teams.map((team) => (
<Card key={team.id} className="flex flex-col justify-between border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-xl font-semibold text-neutral-900">{team.name}</CardTitle>
{team.description ? (
<CardDescription className="mt-1 text-neutral-600">{team.description}</CardDescription>
) : null}
<div className="mt-4 flex flex-wrap gap-2 text-xs text-neutral-500">
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{team.members.length} membro{team.members.length === 1 ? "" : "s"}
</Badge>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{team.queueCount} fila{team.queueCount === 1 ? "" : "s"}
</Badge>
</div>
</div>
<div className="flex flex-col gap-2">
<Button variant="outline" size="sm" onClick={() => openMembership(team)}>
Gerenciar membros
</Button>
<Button variant="secondary" size="sm" onClick={() => openEdit(team)}>
Renomear
</Button>
<Button variant="destructive" size="sm" onClick={() => handleRemove(team)}>
Excluir
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Integrantes</p>
{team.members.length === 0 ? (
<p className="mt-2 text-sm text-neutral-600">Nenhum membro atribuído.</p>
) : (
<ul className="mt-2 space-y-2">
{team.members.map((member) => (
<li key={member.id} className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-sm">
<div>
<p className="font-medium text-neutral-800">{member.name || member.email}</p>
<p className="text-xs text-neutral-500">{member.email}</p>
</div>
<Badge variant="outline" className="rounded-full border-neutral-200 text-neutral-600">
{member.role.toLowerCase()}
</Badge>
</li>
))}
</ul>
)}
</CardContent>
</Card>
))
)}
</div>
<Dialog open={Boolean(editingTeam)} onOpenChange={(value) => (!value ? setEditingTeam(null) : null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Editar time</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="edit-team-name">Nome</Label>
<Input
id="edit-team-name"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-team-description">Descrição</Label>
<Input
id="edit-team-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setEditingTeam(null)}>
Cancelar
</Button>
<Button onClick={handleUpdate} disabled={saving}>
Salvar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(membershipTeam)} onOpenChange={(value) => (!value ? setMembershipTeam(null) : null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Gerenciar membros</DialogTitle>
</DialogHeader>
<div className="max-h-[420px] overflow-y-auto rounded-lg border border-slate-200">
{directory === undefined ? (
<div className="space-y-2 p-4">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-10 rounded" />
))}
</div>
) : directory.length === 0 ? (
<p className="p-4 text-sm text-neutral-600">Nenhum usuário sincronizado para este tenant.</p>
) : (
<ul className="divide-y divide-slate-100">
{directory.map((user) => {
const checked = selectedMembers.has(user.id)
return (
<li key={user.id} className="flex items-center justify-between gap-3 px-4 py-3">
<div>
<p className="text-sm font-medium text-neutral-900">{user.name || user.email}</p>
<p className="text-xs text-neutral-500">{user.email}</p>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className="rounded-full border-neutral-200 text-neutral-600">
{user.role.toLowerCase()}
</Badge>
<Checkbox checked={checked} onCheckedChange={() => toggleMember(user.id)} />
</div>
</li>
)
})}
</ul>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setMembershipTeam(null)}>
Cancelar
</Button>
<Button onClick={handleMembershipSave} disabled={saving}>
Salvar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,25 @@
import type { ReactNode } from "react"
import { AppSidebar } from "@/components/app-sidebar"
import { AuthGuard } from "@/components/auth/auth-guard"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
interface AppShellProps {
header: ReactNode
children: ReactNode
}
export function AppShell({ header, children }: AppShellProps) {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<AuthGuard />
{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">
{children}
</main>
</SidebarInset>
</SidebarProvider>
)
}

View file

@ -0,0 +1,222 @@
"use client"
import * as React from "react"
import {
LayoutDashboard,
LifeBuoy,
Ticket,
PlayCircle,
BookOpen,
BarChart3,
Gauge,
PanelsTopLeft,
Users,
Waypoints,
Timer,
Layers3,
UserPlus,
Settings,
} from "lucide-react"
import { usePathname } from "next/navigation"
import { SearchForm } from "@/components/search-form"
import { VersionSwitcher } from "@/components/version-switcher"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar"
import { Skeleton } from "@/components/ui/skeleton"
import { NavUser } from "@/components/nav-user"
import { useAuth } from "@/lib/auth-client"
import type { LucideIcon } from "lucide-react"
type NavRoleRequirement = "staff" | "admin" | "customer"
type NavigationItem = {
title: string
url: string
icon: LucideIcon
requiredRole?: NavRoleRequirement
exact?: boolean
}
type NavigationGroup = {
title: string
requiredRole?: NavRoleRequirement
items: NavigationItem[]
}
const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
versions: ["Rever Tecnologia"],
navMain: [
{
title: "Operação",
items: [
{ title: "Dashboard", url: "/dashboard", icon: LayoutDashboard, requiredRole: "staff" },
{ title: "Tickets", url: "/tickets", icon: Ticket, requiredRole: "staff" },
{ title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" },
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
{ title: "Base de conhecimento", url: "/knowledge", icon: BookOpen, requiredRole: "staff" },
],
},
{
title: "Relatórios",
requiredRole: "staff",
items: [
{ title: "SLA e produtividade", url: "/reports/sla", icon: Gauge, requiredRole: "staff" },
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" },
],
},
{
title: "Administração",
requiredRole: "admin",
items: [
{
title: "Convites e acessos",
url: "/admin",
icon: UserPlus,
requiredRole: "admin",
exact: true,
},
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
{ title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" },
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" },
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
],
},
{
title: "Conta",
requiredRole: "staff",
items: [{ title: "Configurações", url: "/settings", icon: Settings, requiredRole: "staff" }],
},
],
}
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname()
const { session, isLoading, isAdmin, isStaff, isCustomer } = useAuth()
const [isHydrated, setIsHydrated] = React.useState(false)
React.useEffect(() => {
setIsHydrated(true)
}, [])
function isActive(item: NavigationItem) {
const { url, exact } = item
if (!pathname) return false
if (url === "/dashboard" && pathname === "/") {
return true
}
if (exact) {
return pathname === url
}
return pathname === url || pathname.startsWith(`${url}/`)
}
function canAccess(requiredRole?: NavRoleRequirement) {
if (!requiredRole) return true
if (requiredRole === "admin") return isAdmin
if (requiredRole === "staff") return isStaff
if (requiredRole === "customer") return isCustomer
return false
}
if (!isHydrated) {
return (
<Sidebar {...props}>
<SidebarHeader className="gap-3">
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-9 w-full rounded-lg" />
</SidebarHeader>
<SidebarContent>
{[0, 1, 2].map((group) => (
<SidebarGroup key={group}>
<SidebarGroupLabel>
<Skeleton className="h-3 w-20 rounded" />
</SidebarGroupLabel>
<SidebarGroupContent>
<div className="space-y-2">
{[0, 1, 2].map((item) => (
<Skeleton key={item} className="h-9 w-full rounded-md" />
))}
</div>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarRail />
</Sidebar>
)
}
return (
<Sidebar {...props}>
<SidebarHeader className="gap-3">
<VersionSwitcher
label="Sistema de chamados"
versions={[...navigation.versions]}
defaultVersion={navigation.versions[0]}
/>
<SearchForm placeholder="Buscar tickets" />
</SidebarHeader>
<SidebarContent>
{navigation.navMain.map((group) => {
if (!canAccess(group.requiredRole)) return null
const visibleItems = group.items.filter((item) => canAccess(item.requiredRole))
if (visibleItems.length === 0) return null
return (
<SidebarGroup key={group.title}>
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{visibleItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive(item)}>
<a href={item.url} className="gap-2">
<item.icon className="size-4" />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
})}
</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 />
</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

@ -0,0 +1,19 @@
"use client"
import { MeshGradient } from "@paper-design/shaders-react"
export default function BackgroundPaperShadersWrapper() {
const speed = 1.0
return (
<div className="w-full h-full bg-black relative overflow-hidden">
<MeshGradient
className="w-full h-full absolute inset-0"
colors={["#000000", "#1a1a1a", "#333333", "#ffffff"]}
speed={speed * 0.5}
wireframe="true"
/>
</div>
)
}

View file

@ -0,0 +1,116 @@
"use client"
import { useRef, useMemo } from "react"
import { useFrame } from "@react-three/fiber"
import * as THREE from "three"
// Custom shader material for advanced effects
const vertexShader = `
uniform float time;
uniform float intensity;
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vUv = uv;
vPosition = position;
vec3 pos = position;
pos.y += sin(pos.x * 10.0 + time) * 0.1 * intensity;
pos.x += cos(pos.y * 8.0 + time * 1.5) * 0.05 * intensity;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`
const fragmentShader = `
uniform float time;
uniform float intensity;
uniform vec3 color1;
uniform vec3 color2;
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vec2 uv = vUv;
// Create animated noise pattern
float noise = sin(uv.x * 20.0 + time) * cos(uv.y * 15.0 + time * 0.8);
noise += sin(uv.x * 35.0 - time * 2.0) * cos(uv.y * 25.0 + time * 1.2) * 0.5;
// Mix colors based on noise and position
vec3 color = mix(color1, color2, noise * 0.5 + 0.5);
color = mix(color, vec3(1.0), pow(abs(noise), 2.0) * intensity);
// Add glow effect
float glow = 1.0 - length(uv - 0.5) * 2.0;
glow = pow(glow, 2.0);
gl_FragColor = vec4(color * glow, glow * 0.8);
}
`
export function ShaderPlane({
position,
color1 = "#ff5722",
color2 = "#ffffff",
}: {
position: [number, number, number]
color1?: string
color2?: string
}) {
const mesh = useRef<THREE.Mesh>(null)
const uniforms = useMemo(
() => ({
time: { value: 0 },
intensity: { value: 1.0 },
color1: { value: new THREE.Color(color1) },
color2: { value: new THREE.Color(color2) },
}),
[color1, color2],
)
useFrame((state) => {
if (mesh.current) {
uniforms.time.value = state.clock.elapsedTime
uniforms.intensity.value = 1.0 + Math.sin(state.clock.elapsedTime * 2) * 0.3
}
})
return (
<mesh ref={mesh} position={position}>
<planeGeometry args={[2, 2, 32, 32]} />
<shaderMaterial
uniforms={uniforms}
vertexShader={vertexShader}
fragmentShader={fragmentShader}
transparent
side={THREE.DoubleSide}
/>
</mesh>
)
}
export function EnergyRing({
radius = 1,
position = [0, 0, 0],
}: {
radius?: number
position?: [number, number, number]
}) {
const mesh = useRef<THREE.Mesh>(null)
useFrame((state) => {
if (mesh.current) {
mesh.current.rotation.z = state.clock.elapsedTime
mesh.current.material.opacity = 0.5 + Math.sin(state.clock.elapsedTime * 3) * 0.3
}
})
return (
<mesh ref={mesh} position={position}>
<ringGeometry args={[radius * 0.8, radius, 32]} />
<meshBasicMaterial color="#ff5722" transparent opacity={0.6} side={THREE.DoubleSide} />
</mesh>
)
}

View file

@ -0,0 +1,224 @@
"use client"
import * as React from "react"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useIsMobile } from "@/hooks/use-mobile"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
export const description = "Distribuição semanal de tickets por canal"
export function ChartAreaInteractive() {
const isMobile = useIsMobile()
const [timeRange, setTimeRange] = React.useState("7d")
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
React.useEffect(() => {
if (isMobile) {
setTimeRange("7d")
}
}, [isMobile])
const report = useQuery(
api.reports.ticketsByChannel,
convexUserId
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange })
: "skip"
)
const channels = React.useMemo(() => report?.channels ?? [], [report])
const palette = React.useMemo(
() => [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
],
[]
)
const chartConfig = React.useMemo(() => {
const entries = channels.map((channel, index) => [
channel,
{
label: channel
.toLowerCase()
.replace(/_/g, " ")
.replace(/\b\w/g, (letter) => letter.toUpperCase()),
color: palette[index % palette.length],
},
])
return Object.fromEntries(entries) as ChartConfig
}, [channels, palette])
const chartData = React.useMemo(() => {
if (!report?.points) return []
return report.points.map((point) => {
const entry: Record<string, number | string> = { date: point.date }
for (const channel of channels) {
entry[channel] = point.values[channel] ?? 0
}
return entry
})
}, [channels, report])
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>Entrada de tickets por canal</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Distribuição dos canais nos últimos {timeRange.replace("d", " dias")}
</span>
<span className="@[540px]/card:hidden">Período: {timeRange}</span>
</CardDescription>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Selecionar período"
>
<SelectValue placeholder="Selecionar período" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">
Últimos 90 dias
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Últimos 30 dias
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Últimos 7 dias
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
{report === undefined ? (
<div className="flex h-[250px] items-center justify-center">
<Skeleton className="h-24 w-full" />
</div>
) : chartData.length === 0 || channels.length === 0 ? (
<div className="flex h-[250px] items-center justify-center rounded-xl border border-dashed border-border/60 text-sm text-muted-foreground">
Sem dados suficientes no período selecionado.
</div>
) : (
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={chartData}>
<defs>
{channels.map((channel) => (
<linearGradient key={channel} id={`fill-${channel}`} x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor={chartConfig[channel]?.color ?? "var(--chart-1)"}
stopOpacity={0.85}
/>
<stop
offset="95%"
stopColor={chartConfig[channel]?.color ?? "var(--chart-1)"}
stopOpacity={0.1}
/>
</linearGradient>
))}
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString("pt-BR", {
month: "short",
day: "2-digit",
})
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) =>
new Date(value).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "long",
})
}
indicator="dot"
/>
}
/>
{channels
.slice()
.reverse()
.map((channel) => (
<Area
key={channel}
dataKey={channel}
type="natural"
fill={`url(#fill-${channel})`}
stroke={chartConfig[channel]?.color ?? "var(--chart-1)"}
strokeWidth={2}
stackId="a"
name={chartConfig[channel]?.label ?? channel}
/>
))}
</AreaChart>
</ChartContainer>
)}
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,807 @@
"use client"
import * as React from "react"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
type UniqueIdentifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconCircleCheckFilled,
IconDotsVertical,
IconGripVertical,
IconLayoutColumns,
IconLoader,
IconPlus,
IconTrendingUp,
} from "@tabler/icons-react"
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
Row,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import { toast } from "sonner"
import { z } from "zod"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { Checkbox } from "@/components/ui/checkbox"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
export const schema = z.object({
id: z.number(),
header: z.string(),
type: z.string(),
status: z.string(),
target: z.string(),
limit: z.string(),
reviewer: z.string(),
})
// Create a separate component for the drag handle
function DragHandle({ id }: { id: number }) {
const { attributes, listeners } = useSortable({
id,
})
return (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="icon"
className="text-muted-foreground size-7 hover:bg-transparent"
>
<IconGripVertical className="text-muted-foreground size-3" />
<span className="sr-only">Drag to reorder</span>
</Button>
)
}
const columns: ColumnDef<z.infer<typeof schema>>[] = [
{
id: "drag",
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
},
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "header",
header: "Header",
cell: ({ row }) => {
return <TableCellViewer item={row.original} />
},
enableHiding: false,
},
{
accessorKey: "type",
header: "Section Type",
cell: ({ row }) => (
<div className="w-32">
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.type}
</Badge>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<Badge variant="outline" className="text-muted-foreground px-1.5">
{row.original.status === "Done" ? (
<IconCircleCheckFilled className="fill-green-500 dark:fill-green-400" />
) : (
<IconLoader />
)}
{row.original.status}
</Badge>
),
},
{
accessorKey: "target",
header: () => <div className="w-full text-right">Target</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-target`} className="sr-only">
Target
</Label>
<Input
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
defaultValue={row.original.target}
id={`${row.original.id}-target`}
/>
</form>
),
},
{
accessorKey: "limit",
header: () => <div className="w-full text-right">Limit</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-limit`} className="sr-only">
Limit
</Label>
<Input
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
defaultValue={row.original.limit}
id={`${row.original.id}-limit`}
/>
</form>
),
},
{
accessorKey: "reviewer",
header: "Reviewer",
cell: ({ row }) => {
const isAssigned = row.original.reviewer !== "Assign reviewer"
if (isAssigned) {
return row.original.reviewer
}
return (
<>
<Label htmlFor={`${row.original.id}-reviewer`} className="sr-only">
Reviewer
</Label>
<Select>
<SelectTrigger
className="w-38 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate"
size="sm"
id={`${row.original.id}-reviewer`}
>
<SelectValue placeholder="Assign reviewer" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
</SelectContent>
</Select>
</>
)
},
},
{
id: "actions",
cell: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Make a copy</DropdownMenuItem>
<DropdownMenuItem>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
})
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
data-dragging={isDragging}
ref={setNodeRef}
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
export function DataTable({
data: initialData,
}: {
data: z.infer<typeof schema>[]
}) {
const [data, setData] = React.useState(() => initialData)
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [sorting, setSorting] = React.useState<SortingState>([])
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
})
const sortableId = React.useId()
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
const dataIds = React.useMemo<UniqueIdentifier[]>(
() => data?.map(({ id }) => id) || [],
[data]
)
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
getRowId: (row) => row.id.toString(),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (active && over && active.id !== over.id) {
setData((data) => {
const oldIndex = dataIds.indexOf(active.id)
const newIndex = dataIds.indexOf(over.id)
return arrayMove(data, oldIndex, newIndex)
})
}
}
return (
<Tabs
defaultValue="outline"
className="w-full flex-col justify-start gap-6"
>
<div className="flex items-center justify-between px-4 lg:px-6">
<Label htmlFor="view-selector" className="sr-only">
View
</Label>
<Select defaultValue="outline">
<SelectTrigger
className="flex w-fit @4xl/main:hidden"
size="sm"
id="view-selector"
>
<SelectValue placeholder="Select a view" />
</SelectTrigger>
<SelectContent>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="past-performance">Past Performance</SelectItem>
<SelectItem value="key-personnel">Key Personnel</SelectItem>
<SelectItem value="focus-documents">Focus Documents</SelectItem>
</SelectContent>
</Select>
<TabsList className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
<TabsTrigger value="outline">Outline</TabsTrigger>
<TabsTrigger value="past-performance">
Past Performance <Badge variant="secondary">3</Badge>
</TabsTrigger>
<TabsTrigger value="key-personnel">
Key Personnel <Badge variant="secondary">2</Badge>
</TabsTrigger>
<TabsTrigger value="focus-documents">Focus Documents</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Customize Columns</span>
<span className="lg:hidden">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm">
<IconPlus />
<span className="hidden lg:inline">Add Section</span>
</Button>
</div>
</div>
<TabsContent
value="outline"
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
>
<div className="overflow-hidden rounded-lg border">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="bg-muted sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{table.getRowModel().rows?.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
<div className="flex items-center justify-between px-4">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</TabsContent>
<TabsContent
value="past-performance"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent
value="focus-documents"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
</Tabs>
)
}
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 },
]
const chartConfig = {
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
const isMobile = useIsMobile()
return (
<Drawer direction={isMobile ? "bottom" : "right"}>
<DrawerTrigger asChild>
<Button variant="link" className="text-foreground w-fit px-0 text-left">
{item.header}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="gap-1">
<DrawerTitle>{item.header}</DrawerTitle>
<DrawerDescription>
Showing total visitors for the last 6 months
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
{!isMobile && (
<>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={chartData}
margin={{
left: 0,
right: 10,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
hide
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="mobile"
type="natural"
fill="var(--color-mobile)"
fillOpacity={0.6}
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="var(--color-desktop)"
fillOpacity={0.4}
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
<Separator />
<div className="grid gap-2">
<div className="flex gap-2 leading-none font-medium">
Trending up by 5.2% this month{" "}
<IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Showing total visitors for the last 6 months. This is just
some random text to test the layout. It spans multiple lines
and should wrap around.
</div>
</div>
<Separator />
</>
)}
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="header">Header</Label>
<Input id="header" defaultValue={item.header} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="type">Type</Label>
<Select defaultValue={item.type}>
<SelectTrigger id="type" className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Table of Contents">
Table of Contents
</SelectItem>
<SelectItem value="Executive Summary">
Executive Summary
</SelectItem>
<SelectItem value="Technical Approach">
Technical Approach
</SelectItem>
<SelectItem value="Design">Design</SelectItem>
<SelectItem value="Capabilities">Capabilities</SelectItem>
<SelectItem value="Focus Documents">
Focus Documents
</SelectItem>
<SelectItem value="Narrative">Narrative</SelectItem>
<SelectItem value="Cover Page">Cover Page</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="status">Status</Label>
<Select defaultValue={item.status}>
<SelectTrigger id="status" className="w-full">
<SelectValue placeholder="Select a status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Done">Done</SelectItem>
<SelectItem value="In Progress">In Progress</SelectItem>
<SelectItem value="Not Started">Not Started</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="target">Target</Label>
<Input id="target" defaultValue={item.target} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="limit">Limit</Label>
<Input id="limit" defaultValue={item.limit} />
</div>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="reviewer">Reviewer</Label>
<Select defaultValue={item.reviewer}>
<SelectTrigger id="reviewer" className="w-full">
<SelectValue placeholder="Select a reviewer" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
<SelectItem value="Emily Whalen">Emily Whalen</SelectItem>
</SelectContent>
</Select>
</div>
</form>
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

View file

@ -0,0 +1,174 @@
"use client"
import { useMemo, useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import type { RoleOption } from "@/lib/authz"
type InviteStatus = "pending" | "accepted" | "revoked" | "expired"
type InviteSummary = {
id: string
email: string
name: string | null
role: RoleOption
tenantId: string
status: InviteStatus
token: string
expiresAt: string
}
function formatDate(dateIso: string) {
return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "long",
timeStyle: "short",
}).format(new Date(dateIso))
}
function statusLabel(status: InviteStatus) {
switch (status) {
case "pending":
return "Pendente"
case "accepted":
return "Aceito"
case "revoked":
return "Revogado"
case "expired":
return "Expirado"
default:
return status
}
}
function statusVariant(status: InviteStatus) {
if (status === "pending") return "secondary"
if (status === "accepted") return "default"
if (status === "revoked") return "destructive"
return "outline"
}
export function InviteAcceptForm({ invite }: { invite: InviteSummary }) {
const router = useRouter()
const [name, setName] = useState(invite.name ?? "")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [isPending, startTransition] = useTransition()
const formattedExpiry = useMemo(() => formatDate(invite.expiresAt), [invite.expiresAt])
const isDisabled = invite.status !== "pending"
function validate() {
if (!password || password.length < 8) {
toast.error("A senha deve ter pelo menos 8 caracteres")
return false
}
if (password !== confirmPassword) {
toast.error("As senhas não coincidem")
return false
}
return true
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (isDisabled) return
if (!validate()) return
startTransition(async () => {
try {
const response = await fetch(`/api/invites/${invite.token}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, password }),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error ?? "Não foi possível aceitar o convite")
}
toast.success("Convite aceito! Faça login para começar.")
router.push(`/login?email=${encodeURIComponent(invite.email)}`)
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao aceitar convite"
toast.error(message)
}
})
}
return (
<div className="space-y-6">
<div className="flex flex-col items-center gap-3 text-center">
<Badge variant={statusVariant(invite.status)} className="rounded-full px-3 py-1 text-xs uppercase tracking-wide">
{statusLabel(invite.status)}
</Badge>
<div className="space-y-1">
<p className="text-sm text-neutral-600">
Convite direcionado para <span className="font-semibold text-neutral-900">{invite.email}</span>
</p>
<p className="text-xs text-neutral-500">
Papel previsto: <span className="uppercase text-neutral-700">{invite.role}</span> Tenant: <span className="uppercase text-neutral-700">{invite.tenantId}</span>
</p>
<p className="text-xs text-neutral-500">Válido até {formattedExpiry}</p>
</div>
</div>
{isDisabled ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-600">
<p>
Este convite encontra-se <span className="font-semibold text-neutral-900">{statusLabel(invite.status).toLowerCase()}</span>.
Solicite um novo convite à equipe administradora caso precise de acesso.
</p>
</div>
) : null}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="invite-name">Nome completo</Label>
<Input
id="invite-name"
placeholder="Seu nome"
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="name"
disabled={isDisabled}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-password">Defina uma senha</Label>
<Input
id="invite-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Mínimo de 8 caracteres"
autoComplete="new-password"
disabled={isDisabled}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-password-confirm">Confirme a senha</Label>
<Input
id="invite-password-confirm"
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
disabled={isDisabled}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isDisabled || isPending}>
{isPending ? "Processando..." : "Ativar acesso"}
</Button>
</form>
</div>
)
}

View file

@ -0,0 +1,124 @@
"use client"
import { useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { Loader2 } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
import { signIn } from "@/lib/auth-client"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
type LoginFormProps = React.ComponentProps<"form"> & {
callbackUrl?: string
disabled?: boolean
}
export function LoginForm({ className, callbackUrl, disabled = false, ...props }: LoginFormProps) {
const router = useRouter()
const searchParams = useSearchParams()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const destination = callbackUrl ?? searchParams?.get("callbackUrl") ?? "/dashboard"
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (isSubmitting || disabled) return
if (!email || !password) {
toast.error("Informe e-mail e senha")
return
}
setIsSubmitting(true)
try {
const result = await signIn.email({ email, password, callbackURL: destination })
if (result?.error) {
toast.error("E-mail ou senha inválidos")
setIsSubmitting(false)
return
}
toast.success("Sessão iniciada com sucesso")
router.replace(destination)
} catch (error) {
console.error("Erro ao autenticar", error)
toast.error("Não foi possível entrar. Tente novamente")
setIsSubmitting(false)
}
}
return (
<form
onSubmit={handleSubmit}
className={cn("flex flex-col gap-6", disabled && "pointer-events-none opacity-70", className)}
{...props}
>
<FieldGroup>
<div className="flex flex-col items-center gap-1 text-center">
<h1 className="text-2xl font-bold">Acesse sua conta</h1>
<p className="text-muted-foreground text-sm text-balance">
Informe seu e-mail corporativo e senha para continuar atendendo os chamados.
</p>
</div>
<Field>
<FieldLabel htmlFor="email">E-mail</FieldLabel>
<Input
id="email"
type="email"
placeholder="agente@sistema.dev"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
disabled={isSubmitting || disabled}
required
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password">Senha</FieldLabel>
<Link
href="/recuperar"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
Esqueceu a senha?
</Link>
</div>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isSubmitting || disabled}
required
/>
</Field>
<Field>
<Button type="submit" disabled={isSubmitting || disabled} className="gap-2">
{(isSubmitting || disabled) && <Loader2 className="size-4 animate-spin" />}
Entrar
</Button>
</Field>
<FieldSeparator />
<Field>
<div className="space-y-1 text-left">
<p className="text-sm font-semibold">Primeiro acesso?</p>
<FieldDescription className="text-sm">
Fale com o nosso suporte por telefone ou e-mail para receber um convite e definir sua senha inicial.
</FieldDescription>
</div>
</Field>
</FieldGroup>
</form>
)
}

View file

@ -0,0 +1,92 @@
"use client"
import {
IconDots,
IconFolder,
IconShare3,
IconTrash,
type Icon,
} from "@tabler/icons-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
export function NavDocuments({
items,
}: {
items: {
name: string
url: string
icon: Icon
}[]
}) {
const { isMobile } = useSidebar()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="data-[state=open]:bg-accent rounded-sm"
>
<IconDots />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<IconFolder />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<IconShare3 />
<span>Share</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<IconTrash />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<IconDots className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}

View file

@ -0,0 +1,58 @@
"use client"
import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function NavMain({
items,
}: {
items: {
title: string
url: string
icon?: Icon
}[]
}) {
return (
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<SidebarMenuButton
tooltip="Quick Create"
className="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
>
<IconCirclePlusFilled />
<span>Quick Create</span>
</SidebarMenuButton>
<Button
size="icon"
className="size-8 group-data-[collapsible=icon]:opacity-0"
variant="outline"
>
<IconMail />
<span className="sr-only">Inbox</span>
</Button>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

View file

@ -0,0 +1,42 @@
"use client"
import * as React from "react"
import { type Icon } from "@tabler/icons-react"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function NavSecondary({
items,
...props
}: {
items: {
title: string
url: string
icon: Icon
}[]
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

154
src/components/nav-user.tsx Normal file
View file

@ -0,0 +1,154 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import {
IconDotsVertical,
IconLogout,
IconNotification,
IconUserCircle,
} from "@tabler/icons-react"
import { toast } from "sonner"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import { signOut } from "@/lib/auth-client"
type NavUserProps = {
user?: {
name?: string | null
email?: string | null
avatarUrl?: string | null
} | null
}
export function NavUser({ user }: NavUserProps) {
const normalizedUser = user ?? { name: null, email: null, avatarUrl: null }
const { isMobile } = useSidebar()
const router = useRouter()
const [isSigningOut, setIsSigningOut] = useState(false)
const initials = useMemo(() => {
const source = normalizedUser.name?.trim() || normalizedUser.email?.trim() || ""
if (!source) return "US"
const parts = source.split(" ").filter(Boolean)
const firstTwo = parts.slice(0, 2).map((part) => part[0]).join("")
if (firstTwo) return firstTwo.toUpperCase()
return source.slice(0, 2).toUpperCase()
}, [normalizedUser.name, normalizedUser.email])
const displayName = normalizedUser.name?.trim() || "Usuário"
const displayEmail = normalizedUser.email?.trim() || "Sem e-mail definido"
const handleProfile = useCallback(() => {
router.push("/settings")
}, [router])
const handleSignOut = useCallback(async () => {
if (isSigningOut) return
setIsSigningOut(true)
try {
await signOut()
toast.success("Sessão encerrada.")
router.replace("/login")
} catch (error) {
console.error("Erro ao encerrar sessão", error)
toast.error("Não foi possível encerrar a sessão.")
} finally {
setIsSigningOut(false)
}
}, [isSigningOut, router])
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg grayscale">
<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>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<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

@ -0,0 +1,42 @@
import type { ReactNode } from "react"
import { Button } from "@/components/ui/button"
interface PageHeaderProps {
title: string
description?: string
actions?: ReactNode
children?: ReactNode
}
export function PageHeader({ title, description, actions, children }: PageHeaderProps) {
return (
<div className="flex flex-col gap-6 border-b px-4 pb-6 pt-4 lg:px-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
{children}
</div>
)
}
PageHeader.Action = function PageHeaderAction({ children }: { children: ReactNode }) {
return <div className="flex items-center gap-2">{children}</div>
}
PageHeader.PrimaryButton = function PageHeaderPrimaryButton({
children,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button size="sm" {...props}>
{children}
</Button>
)
}

View file

@ -0,0 +1,125 @@
"use client"
import { type ReactNode, useMemo, useState } from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { LogOut, PlusCircle } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { cn } from "@/lib/utils"
import { useAuth, signOut } from "@/lib/auth-client"
interface PortalShellProps {
children: ReactNode
}
const navItems = [
{ label: "Meus chamados", href: "/portal/tickets" },
{ label: "Abrir chamado", href: "/portal/tickets/new", icon: PlusCircle },
]
export function PortalShell({ children }: PortalShellProps) {
const pathname = usePathname()
const router = useRouter()
const { session, isCustomer } = useAuth()
const [isSigningOut, setIsSigningOut] = useState(false)
const initials = useMemo(() => {
const name = session?.user.name || session?.user.email || "Cliente"
return name
.split(" ")
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join("")
}, [session?.user.name, session?.user.email])
async function handleSignOut() {
if (isSigningOut) return
setIsSigningOut(true)
toast.loading("Encerrando sessão...", { id: "portal-signout" })
try {
await signOut()
toast.success("Sessão encerrada", { id: "portal-signout" })
router.replace("/login")
} catch (error) {
console.error(error)
toast.error("Não foi possível encerrar a sessão", { id: "portal-signout" })
} finally {
setIsSigningOut(false)
}
}
return (
<div className="flex min-h-screen flex-col bg-gradient-to-b from-slate-50 via-slate-50 to-white">
<header className="border-b border-slate-200 bg-white/90 backdrop-blur">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-4 px-6 py-4">
<div className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-[0.28em] text-neutral-500">
Portal do cliente
</span>
<span className="text-lg font-semibold text-neutral-900">Sistema de chamados</span>
</div>
<nav className="flex items-center gap-3 text-sm font-medium">
{navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`)
const Icon = item.icon
return (
<Link
key={item.href}
href={item.href}
className={cn(
"inline-flex items-center gap-2 rounded-full px-4 py-2 transition",
isActive
? "bg-neutral-900 text-white shadow-sm"
: "bg-transparent text-neutral-700 hover:bg-neutral-100"
)}
>
{Icon ? <Icon className="size-4" /> : null}
{item.label}
</Link>
)
})}
</nav>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm">
<Avatar className="size-9 border border-slate-200">
<AvatarImage src={session?.user.avatarUrl ?? undefined} alt={session?.user.name ?? ""} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col leading-tight">
<span className="font-semibold text-neutral-900">{session?.user.name ?? "Cliente"}</span>
<span className="text-xs text-neutral-500">{session?.user.email ?? ""}</span>
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={handleSignOut}
disabled={isSigningOut}
className="inline-flex items-center gap-2"
>
<LogOut className="size-4" />
Sair
</Button>
</div>
</div>
</header>
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-6 py-8">
{!isCustomer ? (
<div className="rounded-2xl border border-dashed border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Este portal é voltado a clientes. Algumas ações podem não estar disponíveis para o seu perfil.
</div>
) : null}
{children}
</main>
<footer className="border-t border-slate-200 bg-white/70">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4 text-xs text-neutral-500">
<span>&copy; {new Date().getFullYear()} Sistema de chamados</span>
<span>Suporte: suporte@sistema.dev</span>
</div>
</footer>
</div>
)
}

View file

@ -0,0 +1,104 @@
"use client"
import { format } from "date-fns"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import Link from "next/link"
import { Tag } from "lucide-react"
import type { Ticket } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { cn } from "@/lib/utils"
const statusLabel: Record<Ticket["status"], string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
const statusTone: Record<Ticket["status"], string> = {
NEW: "bg-slate-200 text-slate-800",
OPEN: "bg-sky-100 text-sky-700",
PENDING: "bg-amber-100 text-amber-700",
ON_HOLD: "bg-violet-100 text-violet-700",
RESOLVED: "bg-emerald-100 text-emerald-700",
CLOSED: "bg-slate-100 text-slate-600",
}
const priorityLabel: Record<Ticket["priority"], string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}
const priorityTone: Record<Ticket["priority"], string> = {
LOW: "bg-slate-100 text-slate-600",
MEDIUM: "bg-sky-100 text-sky-700",
HIGH: "bg-amber-100 text-amber-700",
URGENT: "bg-rose-100 text-rose-700",
}
interface PortalTicketCardProps {
ticket: Ticket
}
export function PortalTicketCard({ ticket }: PortalTicketCardProps) {
const updatedAgo = formatDistanceToNow(ticket.updatedAt, {
addSuffix: true,
locale: ptBR,
})
return (
<Link href={`/portal/tickets/${ticket.id}`} className="block">
<Card className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="flex flex-row items-start justify-between gap-3 px-5 pb-3 pt-5">
<div>
<div className="flex items-center gap-2 text-sm text-neutral-500">
<span className="font-semibold text-neutral-900">#{ticket.reference}</span>
<span>&middot;</span>
<span>{format(ticket.createdAt, "dd/MM/yyyy")}</span>
</div>
<h3 className="mt-1 text-lg font-semibold text-neutral-900">{ticket.subject}</h3>
{ticket.summary ? (
<p className="mt-1 line-clamp-2 text-sm text-neutral-600">{ticket.summary}</p>
) : null}
</div>
<div className="flex flex-col items-end gap-2 text-right">
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold uppercase", statusTone[ticket.status])}>
{statusLabel[ticket.status]}
</Badge>
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold uppercase", priorityTone[ticket.priority])}>
{priorityLabel[ticket.priority]}
</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-wrap items-center justify-between gap-4 border-t border-slate-100 px-5 py-4 text-sm text-neutral-600">
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-neutral-500">Fila</span>
<span className="font-medium text-neutral-800">{ticket.queue ?? "Sem fila"}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-neutral-500">Status</span>
<span className="font-medium text-neutral-800">{statusLabel[ticket.status]}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-neutral-500">Última atualização</span>
<span className="font-medium text-neutral-800">{updatedAgo}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-neutral-500">Categoria</span>
<span className="flex items-center gap-2 font-medium text-neutral-800">
<Tag className="size-4 text-neutral-500" />
{ticket.category?.name ?? "Não classificada"}
</span>
</div>
</CardContent>
</Card>
</Link>
)
}

View file

@ -0,0 +1,303 @@
"use client"
import { useMemo, useState } from "react"
import { useQuery, useMutation } from "convex/react"
import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { MessageCircle } from "lucide-react"
import { toast } from "sonner"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Skeleton } from "@/components/ui/skeleton"
import { Textarea } from "@/components/ui/textarea"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
const statusLabel: Record<TicketWithDetails["status"], string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
const priorityLabel: Record<TicketWithDetails["priority"], string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}
const priorityTone: Record<TicketWithDetails["priority"], string> = {
LOW: "bg-slate-100 text-slate-600",
MEDIUM: "bg-sky-100 text-sky-700",
HIGH: "bg-amber-100 text-amber-700",
URGENT: "bg-rose-100 text-rose-700",
}
const timelineLabels: Record<string, string> = {
CREATED: "Chamado criado",
STATUS_CHANGED: "Status atualizado",
ASSIGNEE_CHANGED: "Responsável alterado",
COMMENT_ADDED: "Novo comentário",
COMMENT_EDITED: "Comentário editado",
ATTACHMENT_REMOVED: "Anexo removido",
QUEUE_CHANGED: "Fila atualizada",
}
function toHtmlFromText(text: string) {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
return `<p>${escaped.replace(/\n/g, "<br />")}</p>`
}
interface PortalTicketDetailProps {
ticketId: string
}
export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
const { convexUserId, session } = useAuth()
const addComment = useMutation(api.tickets.addComment)
const [comment, setComment] = useState("")
const ticketRaw = useQuery(
api.tickets.getById,
convexUserId
? {
tenantId: session?.user.tenantId ?? DEFAULT_TENANT_ID,
id: ticketId as Id<"tickets">,
viewerId: convexUserId as Id<"users">,
}
: "skip"
)
const ticket = useMemo(() => {
if (!ticketRaw) return null
return mapTicketWithDetailsFromServer(ticketRaw)
}, [ticketRaw])
if (ticketRaw === undefined) {
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-5 py-5">
<CardTitle className="text-lg font-semibold text-neutral-900">Carregando ticket...</CardTitle>
</CardHeader>
<CardContent className="space-y-3 px-5 pb-6">
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</CardContent>
</Card>
)
}
if (!ticket) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<span className="text-2xl">🔍</span>
</EmptyMedia>
<EmptyTitle className="text-neutral-900">Ticket não encontrado</EmptyTitle>
<EmptyDescription className="text-neutral-600">
Verifique o endereço ou retorne à lista de chamados.
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
const createdAt = format(ticket.createdAt, "dd 'de' MMMM 'de' yyyy 'às' HH:mm", { locale: ptBR })
const updatedAgo = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!convexUserId || !comment.trim()) return
const toastId = "portal-add-comment"
toast.loading("Enviando comentário...", { id: toastId })
try {
const htmlBody = sanitizeEditorHtml(toHtmlFromText(comment.trim()))
await addComment({
ticketId: ticket.id as Id<"tickets">,
authorId: convexUserId as Id<"users">,
visibility: "PUBLIC",
body: htmlBody,
attachments: [],
})
setComment("")
toast.success("Comentário enviado!", { id: toastId })
} catch (error) {
console.error(error)
toast.error("Não foi possível enviar o comentário.", { id: toastId })
}
}
return (
<div className="space-y-6">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-5 pb-3 pt-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-sm text-neutral-500">Ticket #{ticket.reference}</p>
<h1 className="mt-1 text-2xl font-semibold text-neutral-900">{ticket.subject}</h1>
{ticket.summary ? (
<p className="mt-2 max-w-3xl text-sm text-neutral-600">{ticket.summary}</p>
) : null}
</div>
<div className="flex flex-col items-end gap-2 text-sm">
<Badge className="rounded-full bg-neutral-900 px-3 py-1 text-xs font-semibold uppercase text-white">
{statusLabel[ticket.status]}
</Badge>
<Badge className={`rounded-full px-3 py-1 text-xs font-semibold uppercase ${priorityTone[ticket.priority]}`}>
{priorityLabel[ticket.priority]}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-4 border-t border-slate-100 px-5 py-5 text-sm text-neutral-700 sm:grid-cols-2">
<DetailItem label="Fila" value={ticket.queue ?? "Sem fila"} />
<DetailItem label="Categoria" value={ticket.category?.name ?? "Não classificada"} />
<DetailItem label="Solicitante" value={ticket.requester.name} subtitle={ticket.requester.email} />
<DetailItem label="Responsável" value={ticket.assignee?.name ?? "Equipe de suporte"} />
<DetailItem label="Criado em" value={createdAt} />
<DetailItem label="Última atualização" value={updatedAgo} />
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="flex flex-row items-center justify-between px-5 py-4">
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<MessageCircle className="size-5 text-neutral-500" /> Conversas
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-5 pb-6">
<form onSubmit={handleSubmit} className="space-y-3">
<label htmlFor="comment" className="text-sm font-medium text-neutral-800">
Enviar uma mensagem para a equipe
</label>
<Textarea
id="comment"
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="Descreva o que aconteceu, envie atualizações ou compartilhe novas informações."
className="min-h-[120px] resize-y rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-neutral-800 shadow-sm focus-visible:border-neutral-900 focus-visible:ring-neutral-900/20"
/>
<div className="flex justify-end">
<Button type="submit" className="rounded-full bg-neutral-900 px-6 text-sm font-semibold text-white hover:bg-neutral-900/90">
Enviar comentário
</Button>
</div>
</form>
<div className="space-y-5">
{ticket.comments.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<MessageCircle className="size-5 text-neutral-500" />
</EmptyMedia>
<EmptyTitle className="text-neutral-900">Nenhum comentário ainda</EmptyTitle>
<EmptyDescription className="text-neutral-600">
Registre a primeira atualização acima.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
ticket.comments.map((commentItem) => {
const initials = commentItem.author.name
.split(" ")
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join("")
const createdAgo = formatDistanceToNow(commentItem.createdAt, {
addSuffix: true,
locale: ptBR,
})
return (
<div key={commentItem.id} className="rounded-xl border border-slate-100 bg-slate-50/70 p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Avatar className="size-9 border border-slate-200">
<AvatarImage src={commentItem.author.avatarUrl} alt={commentItem.author.name} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-semibold text-neutral-900">{commentItem.author.name}</span>
<span className="text-xs text-neutral-500">{createdAgo}</span>
</div>
</div>
<Badge variant="outline" className="rounded-full border-dashed px-3 py-1 text-[11px] uppercase text-neutral-600">
{commentItem.visibility === "PUBLIC" ? "Público" : "Interno"}
</Badge>
</div>
<div
className="prose prose-sm mt-3 max-w-none text-neutral-800"
dangerouslySetInnerHTML={{ __html: sanitizeEditorHtml(commentItem.body ?? "") }}
/>
</div>
)
})
)}
</div>
</CardContent>
</Card>
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-5 py-4">
<CardTitle className="text-lg font-semibold text-neutral-900">Linha do tempo</CardTitle>
</CardHeader>
<CardContent className="space-y-5 px-5 pb-6 text-sm text-neutral-700">
{ticket.timeline.length === 0 ? (
<p className="text-sm text-neutral-500">Nenhum evento registrado ainda.</p>
) : (
ticket.timeline
.slice()
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.map((event) => {
const label = timelineLabels[event.type] ?? event.type
const when = formatDistanceToNow(event.createdAt, { addSuffix: true, locale: ptBR })
return (
<div key={event.id} className="flex flex-col gap-1 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<span className="text-sm font-semibold text-neutral-900">{label}</span>
<span className="text-xs text-neutral-500">{when}</span>
</div>
)
})
)}
</CardContent>
</Card>
</div>
</div>
)
}
interface DetailItemProps {
label: string
value: string
subtitle?: string | null
}
function DetailItem({ label, value, subtitle }: DetailItemProps) {
return (
<div className="rounded-xl border border-dashed border-slate-200 bg-white/60 px-4 py-3 shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
<p className="text-xs uppercase tracking-wide text-neutral-500">{label}</p>
<p className="text-sm font-medium text-neutral-900">{value}</p>
{subtitle ? <p className="text-xs text-neutral-500">{subtitle}</p> : null}
</div>
)
}

View file

@ -0,0 +1,199 @@
"use client"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useMutation } from "convex/react"
import { toast } from "sonner"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { TicketPriority } from "@/lib/schemas/ticket"
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { useAuth } from "@/lib/auth-client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { CategorySelectFields } from "@/components/tickets/category-select"
const priorityLabel: Record<TicketPriority, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}
function toHtml(text: string) {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
return `<p>${escaped.replace(/\n/g, "<br />")}</p>`
}
export function PortalTicketForm() {
const router = useRouter()
const { convexUserId, session } = useAuth()
const createTicket = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment)
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const [subject, setSubject] = useState("")
const [summary, setSummary] = useState("")
const [description, setDescription] = useState("")
const [priority, setPriority] = useState<TicketPriority>("MEDIUM")
const [categoryId, setCategoryId] = useState<string | null>(null)
const [subcategoryId, setSubcategoryId] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const isFormValid = useMemo(() => {
return Boolean(subject.trim() && description.trim() && categoryId && subcategoryId)
}, [subject, description, categoryId, subcategoryId])
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!convexUserId || !isFormValid || isSubmitting) return
const trimmedSubject = subject.trim()
const trimmedSummary = summary.trim()
const trimmedDescription = description.trim()
setIsSubmitting(true)
toast.loading("Abrindo chamado...", { id: "portal-new-ticket" })
try {
const id = await createTicket({
actorId: convexUserId as Id<"users">,
tenantId,
subject: trimmedSubject,
summary: trimmedSummary || undefined,
priority,
channel: "MANUAL",
queueId: undefined,
requesterId: convexUserId as Id<"users">,
categoryId: categoryId as Id<"ticketCategories">,
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
})
if (trimmedDescription.length > 0) {
const htmlBody = sanitizeEditorHtml(toHtml(trimmedDescription))
await addComment({
ticketId: id as Id<"tickets">,
authorId: convexUserId as Id<"users">,
visibility: "PUBLIC",
body: htmlBody,
attachments: [],
})
}
toast.success("Chamado criado com sucesso!", { id: "portal-new-ticket" })
router.replace(`/portal/tickets/${id}`)
} catch (error) {
console.error(error)
toast.error("Não foi possível abrir o chamado.", { id: "portal-new-ticket" })
} finally {
setIsSubmitting(false)
}
}
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-5 py-5">
<CardTitle className="text-xl font-semibold text-neutral-900">Abrir novo chamado</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-5 pb-6">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-3">
<div className="space-y-1">
<label htmlFor="subject" className="flex items-center gap-1 text-sm font-medium text-neutral-800">
Assunto <span className="text-red-500">*</span>
</label>
<Input
id="subject"
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Ex.: Problema de acesso ao sistema"
required
/>
</div>
<div className="space-y-1">
<label htmlFor="summary" className="text-sm font-medium text-neutral-800">
Resumo (opcional)
</label>
<Input
id="summary"
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder="Descreva rapidamente o que está acontecendo"
/>
</div>
<div className="space-y-1">
<label htmlFor="description" className="flex items-center gap-1 text-sm font-medium text-neutral-800">
Detalhes <span className="text-red-500">*</span>
</label>
<Textarea
id="description"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Compartilhe passos para reproduzir, mensagens de erro ou informações adicionais."
required
className="min-h-[140px] resize-y rounded-xl border border-slate-200 px-4 py-3 text-sm text-neutral-800 shadow-sm focus-visible:border-neutral-900 focus-visible:ring-neutral-900/20"
/>
</div>
</div>
<div className="space-y-4">
<div className="space-y-1">
<span className="text-sm font-medium text-neutral-800">Prioridade</span>
<Select value={priority} onValueChange={(value) => setPriority(value as TicketPriority)}>
<SelectTrigger className="h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-neutral-900">
<SelectValue />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{(Object.keys(priorityLabel) as TicketPriority[]).map((option) => (
<SelectItem key={option} value={option} className="text-sm">
{priorityLabel[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<CategorySelectFields
tenantId={tenantId}
categoryId={categoryId}
subcategoryId={subcategoryId}
onCategoryChange={setCategoryId}
onSubcategoryChange={setSubcategoryId}
layout="stacked"
categoryLabel="Categoria *"
subcategoryLabel="Subcategoria *"
secondaryEmptyLabel="Selecione uma categoria"
/>
</div>
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => router.push("/portal/tickets")}
>
Cancelar
</Button>
<Button
type="submit"
disabled={!isFormValid || isSubmitting}
className="rounded-full bg-neutral-900 px-6 text-sm font-semibold text-white hover:bg-neutral-900/90"
>
Registrar chamado
</Button>
</div>
</form>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,89 @@
"use client"
import { useMemo } from "react"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Skeleton } from "@/components/ui/skeleton"
import { PortalTicketCard } from "@/components/portal/portal-ticket-card"
export function PortalTicketList() {
const { convexUserId, session } = useAuth()
const ticketsRaw = useQuery(
api.tickets.list,
convexUserId
? {
tenantId: session?.user.tenantId ?? DEFAULT_TENANT_ID,
viewerId: convexUserId as Id<"users">,
limit: 100,
}
: "skip"
)
const tickets = useMemo(() => {
if (!ticketsRaw) return []
return mapTicketsFromServerList((ticketsRaw as unknown[]) ?? [])
}, [ticketsRaw])
if (ticketsRaw === undefined) {
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-5 py-5">
<CardTitle className="text-lg font-semibold text-neutral-900">Carregando chamados...</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 px-5 pb-6">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-[132px] w-full rounded-xl" />
))}
</CardContent>
</Card>
)
}
if (!tickets.length) {
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-5 py-5">
<CardTitle className="text-lg font-semibold text-neutral-900">Meus chamados</CardTitle>
</CardHeader>
<CardContent className="px-5 pb-6">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<span className="text-2xl">📭</span>
</EmptyMedia>
<EmptyTitle className="text-neutral-900">Nenhum chamado aberto</EmptyTitle>
<EmptyDescription className="text-neutral-600">
Quando você registrar um chamado, ele aparecerá aqui. Clique em Abrir chamado para iniciar um novo atendimento.
</EmptyDescription>
</EmptyHeader>
</Empty>
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-neutral-900">Meus chamados</h2>
<p className="text-sm text-neutral-600">Acompanhe seus tickets e veja as últimas atualizações.</p>
</div>
</div>
<div className="grid gap-4">
{(tickets as Ticket[]).map((ticket) => (
<PortalTicketCard key={ticket.id} ticket={ticket} />
))}
</div>
</div>
)
}

View file

@ -0,0 +1,172 @@
"use client"
import { useMemo } from "react"
import { useQuery } from "convex/react"
import { IconInbox, IconAlertTriangle, IconFilter } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
const PRIORITY_LABELS: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Crítica",
}
const STATUS_LABELS: Record<string, string> = {
NEW: "Novo",
OPEN: "Em andamento",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Encerrado",
}
export function BacklogReport() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const data = useQuery(
api.reports.backlogOverview,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
)
const mostCriticalPriority = useMemo(() => {
if (!data) return null
const entries = Object.entries(data.priorityCounts)
if (entries.length === 0) return null
return entries.reduce((prev, current) => (current[1] > prev[1] ? current : prev))
}, [data])
if (!data) {
return (
<div className="space-y-6">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-32 rounded-2xl" />
))}
</div>
)
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconInbox className="size-4 text-neutral-500" /> Tickets em aberto
</CardTitle>
<CardDescription className="text-neutral-600">Backlog total em atendimento.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{data.totalOpen}</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconAlertTriangle className="size-4 text-amber-500" /> Prioridade predominante
</CardTitle>
<CardDescription className="text-neutral-600">Volume por prioridade no backlog.</CardDescription>
</CardHeader>
<CardContent className="text-xl font-semibold text-neutral-900">
{mostCriticalPriority ? (
<span>
{PRIORITY_LABELS[mostCriticalPriority[0]] ?? mostCriticalPriority[0]} ({mostCriticalPriority[1]})
</span>
) : (
"—"
)}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconFilter className="size-4 text-neutral-500" /> Status acompanhados
</CardTitle>
<CardDescription className="text-neutral-600">Distribuição dos tickets por status.</CardDescription>
</CardHeader>
<CardContent className="text-xl font-semibold text-neutral-900">
{Object.keys(data.statusCounts).length}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Status do backlog</CardTitle>
<CardDescription className="text-neutral-600">
Acompanhe a evolução dos tickets pelas fases do fluxo de atendimento.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Object.entries(data.statusCounts).map(([status, total]) => (
<div key={status} className="rounded-xl border border-slate-200 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
{STATUS_LABELS[status] ?? status}
</p>
<p className="mt-2 text-2xl font-semibold text-neutral-900">{total}</p>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Backlog por prioridade</CardTitle>
<CardDescription className="text-neutral-600">
Analise a pressão de atendimento conforme o nível de urgência.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{Object.entries(data.priorityCounts).map(([priority, total]) => (
<div key={priority} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-sm font-medium text-neutral-800">
{PRIORITY_LABELS[priority] ?? priority}
</span>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{total} ticket{total === 1 ? "" : "s"}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Filas com maior backlog</CardTitle>
<CardDescription className="text-neutral-600">
Identifique onde concentrar esforços de atendimento.
</CardDescription>
</CardHeader>
<CardContent>
{data.queueCounts.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Nenhuma fila com tickets abertos no momento.
</p>
) : (
<ul className="space-y-3">
{data.queueCounts.map((queue) => (
<li key={queue.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<div className="flex flex-col">
<span className="text-sm font-medium text-neutral-900">{queue.name}</span>
</div>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{queue.total} em aberto
</Badge>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,111 @@
"use client"
import { useQuery } from "convex/react"
import { IconMoodSmile, IconStars, IconMessageCircle2 } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
function formatScore(value: number | null) {
if (value === null) return "—"
return value.toFixed(2)
}
export function CsatReport() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const data = useQuery(
api.reports.csatOverview,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
)
if (!data) {
return (
<div className="space-y-6">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} className="h-32 rounded-2xl" />
))}
</div>
)
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconMoodSmile className="size-4 text-teal-500" /> CSAT médio
</CardTitle>
<CardDescription className="text-neutral-600">Média das respostas recebidas.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{formatScore(data.averageScore)}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconStars className="size-4 text-violet-500" /> Total de respostas
</CardTitle>
<CardDescription className="text-neutral-600">Avaliações coletadas nos tickets.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{data.totalSurveys}</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconMessageCircle2 className="size-4 text-sky-500" /> Últimas avaliações
</CardTitle>
<CardDescription className="text-neutral-600">Até 10 registros mais recentes.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{data.recent.length === 0 ? (
<p className="text-sm text-neutral-500">Ainda não coletamos nenhuma avaliação.</p>
) : (
data.recent.map((item) => (
<div key={`${item.ticketId}-${item.receivedAt}`} className="flex items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-sm">
<span>#{item.reference}</span>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
Nota {item.score}
</Badge>
</div>
))
)}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Distribuição das notas</CardTitle>
<CardDescription className="text-neutral-600">
Frequência de respostas para cada valor na escala de 1 a 5.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{data.distribution.map((entry) => (
<li key={entry.score} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
Nota {entry.score}
</Badge>
<span className="text-sm text-neutral-700">{entry.total} respostas</span>
</div>
<span className="text-sm font-medium text-neutral-900">
{data.totalSurveys === 0 ? "0%" : `${((entry.total / data.totalSurveys) * 100).toFixed(0)}%`}
</span>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,124 @@
"use client"
import { useMemo } from "react"
import { useQuery } from "convex/react"
import { IconAlertTriangle, IconGraph, IconClockHour4 } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
function formatMinutes(value: number | null) {
if (value === null) return "—"
if (value < 60) return `${value.toFixed(0)} min`
const hours = Math.floor(value / 60)
const minutes = Math.round(value % 60)
if (minutes === 0) return `${hours}h`
return `${hours}h ${minutes}min`
}
export function SlaReport() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const data = useQuery(
api.reports.slaOverview,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
)
const queueTotal = useMemo(() => data?.queueBreakdown.reduce((acc, queue) => acc + queue.open, 0) ?? 0, [data])
if (!data) {
return (
<div className="space-y-6">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-32 rounded-2xl" />
))}
</div>
)
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-4">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tickets abertos</CardTitle>
<CardDescription className="text-neutral-600">Chamados ativos acompanhados pelo SLA.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{data.totals.open}</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconAlertTriangle className="size-4 text-amber-500" /> Vencidos
</CardTitle>
<CardDescription className="text-neutral-600">Tickets que ultrapassaram o prazo previsto.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{data.totals.overdue}</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconClockHour4 className="size-4 text-neutral-500" /> Tempo resposta médio
</CardTitle>
<CardDescription className="text-neutral-600">Com base nos tickets respondidos.</CardDescription>
</CardHeader>
<CardContent className="text-2xl font-semibold text-neutral-900">
{formatMinutes(data.response.averageFirstResponseMinutes ?? null)}
<p className="mt-1 text-xs text-neutral-500">{data.response.responsesRegistered} registros</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconGraph className="size-4 text-neutral-500" /> Tempo resolução médio
</CardTitle>
<CardDescription className="text-neutral-600">Chamados finalizados no período analisado.</CardDescription>
</CardHeader>
<CardContent className="text-2xl font-semibold text-neutral-900">
{formatMinutes(data.resolution.averageResolutionMinutes ?? null)}
<p className="mt-1 text-xs text-neutral-500">{data.resolution.resolvedCount} resolvidos</p>
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="text-lg font-semibold text-neutral-900">Fila x Volume aberto</CardTitle>
<CardDescription className="text-neutral-600">
Distribuição dos {queueTotal} tickets abertos por fila de atendimento.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{data.queueBreakdown.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Nenhuma fila com tickets ativos no momento.
</p>
) : (
<ul className="space-y-3">
{data.queueBreakdown.map((queue) => (
<li key={queue.id} className="flex items-center justify-between gap-4 rounded-xl border border-slate-200 px-4 py-3">
<div className="flex flex-col">
<span className="text-sm font-medium text-neutral-900">{queue.name}</span>
<span className="text-xs text-neutral-500">{((queue.open / Math.max(queueTotal, 1)) * 100).toFixed(0)}% do volume aberto</span>
</div>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{queue.open} tickets
</Badge>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,28 @@
import { Search } from "lucide-react"
import { Label } from "@/components/ui/label"
import {
SidebarGroup,
SidebarGroupContent,
SidebarInput,
} from "@/components/ui/sidebar"
type SearchFormProps = React.ComponentProps<"form"> & {
placeholder?: string
}
export function SearchForm({ placeholder = "Buscar...", ...props }: SearchFormProps) {
return (
<form {...props}>
<SidebarGroup className="py-0">
<SidebarGroupContent className="relative">
<Label htmlFor="search" className="sr-only">
Busca global
</Label>
<SidebarInput id="search" placeholder={placeholder} className="pl-8" />
<Search className="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 opacity-50 select-none" />
</SidebarGroupContent>
</SidebarGroup>
</form>
)
}

View file

@ -0,0 +1,171 @@
"use client"
import { useMemo } from "react"
import { useQuery } from "convex/react"
import { IconClockHour4, IconMessages, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Badge } from "@/components/ui/badge"
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
function formatMinutes(value: number | null) {
if (value === null) return "—"
if (value < 60) return `${Math.round(value)} min`
const hours = Math.floor(value / 60)
const minutes = Math.round(value % 60)
if (minutes === 0) return `${hours}h`
return `${hours}h ${minutes}min`
}
function formatScore(value: number | null) {
if (value === null) return "—"
return value.toFixed(2)
}
export function SectionCards() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const dashboard = useQuery(
api.reports.dashboardOverview,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
)
const trendInfo = useMemo(() => {
if (!dashboard?.newTickets) return { value: null, label: "Aguardando dados", icon: IconTrendingUp }
const trend = dashboard.newTickets.trendPercentage
if (trend === null) {
return { value: null, label: "Sem histórico", icon: IconTrendingUp }
}
const positive = trend >= 0
const icon = positive ? IconTrendingUp : IconTrendingDown
const label = `${positive ? "+" : ""}${trend.toFixed(1)}%`
return { value: trend, label, icon }
}, [dashboard])
const responseDelta = useMemo(() => {
if (!dashboard?.firstResponse) return { delta: null, label: "Sem dados", positive: false }
const delta = dashboard.firstResponse.deltaMinutes
if (delta === null) return { delta: null, label: "Sem comparação", positive: false }
const positive = delta <= 0
const value = `${delta > 0 ? "+" : ""}${Math.round(delta)} min`
return { delta, label: value, positive }
}, [dashboard])
const TrendIcon = trendInfo.icon
return (
<div className="grid grid-cols-1 gap-4 px-4 sm:grid-cols-2 xl:grid-cols-4 xl:px-8">
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3">
<CardDescription>Tickets novos</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">
{dashboard ? dashboard.newTickets.last24h : <Skeleton className="h-8 w-20" />}
</CardTitle>
<CardAction>
<Badge
variant="outline"
className={`rounded-full gap-1 px-2 py-1 text-xs ${
trendInfo.value !== null && trendInfo.value < 0 ? "text-red-500" : ""
}`}
>
<TrendIcon className="size-3.5" />
{trendInfo.label}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<div className="flex gap-2 text-foreground">
{trendInfo.value === null
? "Aguardando histórico"
: trendInfo.value >= 0
? "Volume acima do período anterior"
: "Volume abaixo do período anterior"}
</div>
<span>Comparação com as 24h anteriores.</span>
</CardFooter>
</Card>
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3">
<CardDescription>Tempo médio da 1ª resposta</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">
{dashboard ? formatMinutes(dashboard.firstResponse.averageMinutes) : <Skeleton className="h-8 w-24" />}
</CardTitle>
<CardAction>
<Badge
variant="outline"
className={`rounded-full gap-1 px-2 py-1 text-xs ${
responseDelta.delta !== null && !responseDelta.positive ? "text-amber-500" : ""
}`}
>
{responseDelta.delta !== null && responseDelta.delta > 0 ? (
<IconTrendingUp className="size-3.5" />
) : (
<IconTrendingDown className="size-3.5" />
)}
{responseDelta.label}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<span className="text-foreground">
{dashboard
? `${dashboard.firstResponse.responsesCount} tickets com primeira resposta`
: "Carregando amostra"}
</span>
<span>Média móvel dos últimos 7 dias.</span>
</CardFooter>
</Card>
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3">
<CardDescription>Tickets aguardando ação</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">
{dashboard ? dashboard.awaitingAction.total : <Skeleton className="h-8 w-16" />}
</CardTitle>
<CardAction>
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
<IconClockHour4 className="size-3.5" />
{dashboard ? `${dashboard.awaitingAction.atRisk} em risco` : "—"}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<span className="text-foreground">Inclui status "Novo", "Aberto" e "Em espera".</span>
<span>Atrasos calculados com base no prazo de SLA.</span>
</CardFooter>
</Card>
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
<CardHeader className="gap-3">
<CardDescription>CSAT recente</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">
{dashboard ? formatScore(dashboard.csat.averageScore) : <Skeleton className="h-8 w-12" />}
</CardTitle>
<CardAction>
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
<IconMessages className="size-3.5" />
{dashboard ? `${dashboard.csat.totalSurveys} pesquisas` : "—"}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
<span className="text-foreground">Notas de satisfação recebidas nos últimos períodos.</span>
<span>Escala de 1 a 5 pontos.</span>
</CardFooter>
</Card>
</div>
)
}

View file

@ -0,0 +1,322 @@
"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconFileText, IconPlus, IconTrash, IconX } from "@tabler/icons-react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { sanitizeEditorHtml, RichTextEditor, RichTextContent } from "@/components/ui/rich-text-editor"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Spinner } from "@/components/ui/spinner"
export function CommentTemplatesManager() {
const { convexUserId, session } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const viewerId = convexUserId as Id<"users"> | undefined
const templates = useQuery(
viewerId ? api.commentTemplates.list : "skip",
viewerId ? { tenantId, viewerId } : "skip"
) as
| {
id: Id<"commentTemplates">
title: string
body: string
createdAt: number
updatedAt: number
createdBy: Id<"users">
updatedBy: Id<"users"> | null
}[]
| undefined
const createTemplate = useMutation(api.commentTemplates.create)
const updateTemplate = useMutation(api.commentTemplates.update)
const deleteTemplate = useMutation(api.commentTemplates.remove)
const [title, setTitle] = useState("")
const [body, setBody] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const isLoading = viewerId && templates === undefined
const orderedTemplates = useMemo(() => templates ?? [], [templates])
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!viewerId) return
const trimmedTitle = title.trim()
const sanitizedBody = sanitizeEditorHtml(body)
if (trimmedTitle.length < 3) {
toast.error("Informe um título com pelo menos 3 caracteres.")
return
}
if (!sanitizedBody) {
toast.error("Escreva o conteúdo do template antes de salvar.")
return
}
setIsSubmitting(true)
toast.loading("Criando template...", { id: "create-template" })
try {
await createTemplate({ tenantId, actorId: viewerId, title: trimmedTitle, body: sanitizedBody })
toast.success("Template criado!", { id: "create-template" })
setTitle("")
setBody("")
} catch (error) {
console.error(error)
toast.error("Não foi possível criar o template.", { id: "create-template" })
} finally {
setIsSubmitting(false)
}
}
async function handleUpdate(templateId: Id<"commentTemplates">, nextTitle: string, nextBody: string) {
if (!viewerId) return
const trimmedTitle = nextTitle.trim()
const sanitizedBody = sanitizeEditorHtml(nextBody)
if (trimmedTitle.length < 3) {
toast.error("Informe um título com pelo menos 3 caracteres.")
return false
}
if (!sanitizedBody) {
toast.error("Escreva o conteúdo do template antes de salvar.")
return false
}
const toastId = `update-template-${templateId}`
toast.loading("Atualizando template...", { id: toastId })
try {
await updateTemplate({
templateId,
tenantId,
actorId: viewerId,
title: trimmedTitle,
body: sanitizedBody,
})
toast.success("Template atualizado!", { id: toastId })
return true
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o template.", { id: toastId })
return false
}
}
async function handleDelete(templateId: Id<"commentTemplates">) {
if (!viewerId) return
const toastId = `delete-template-${templateId}`
toast.loading("Removendo template...", { id: toastId })
try {
await deleteTemplate({ templateId, tenantId, actorId: viewerId })
toast.success("Template removido!", { id: toastId })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover o template.", { id: toastId })
}
}
if (!viewerId) {
return (
<Card className="border border-slate-200">
<CardHeader>
<CardTitle>Templates de comentário</CardTitle>
<CardDescription>Faça login para gerenciar os templates de resposta rápida.</CardDescription>
</CardHeader>
</Card>
)
}
return (
<div className="space-y-6">
<Card className="border border-slate-200">
<CardHeader className="flex flex-col gap-1">
<CardTitle className="text-xl font-semibold text-neutral-900">Templates de comentário</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Mantenha respostas rápidas prontas para uso. Administradores e agentes podem criar, editar e remover templates.
</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleCreate}>
<div className="space-y-2">
<label htmlFor="template-title" className="text-sm font-medium text-neutral-800">
Título do template
</label>
<Input
id="template-title"
placeholder="Ex.: A Rever agradece seu contato"
value={title}
onChange={(event) => setTitle(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<label htmlFor="template-body" className="text-sm font-medium text-neutral-800">
Conteúdo padrão
</label>
<RichTextEditor value={body} onChange={setBody} minHeight={180} placeholder="Escreva a mensagem padrão..." />
</div>
<div className="flex justify-end gap-2">
{body ? (
<Button
type="button"
variant="outline"
className="inline-flex items-center gap-2"
onClick={() => {
setBody("")
setTitle("")
}}
disabled={isSubmitting}
>
<IconX className="size-4" />
Limpar
</Button>
) : null}
<Button type="submit" className="inline-flex items-center gap-2" disabled={isSubmitting}>
{isSubmitting ? <Spinner className="size-4 text-white" /> : <IconPlus className="size-4" />}Salvar template
</Button>
</div>
</form>
</CardContent>
</Card>
<Card className="border border-slate-200">
<CardHeader className="flex flex-col gap-1">
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<IconFileText className="size-5 text-neutral-500" /> Templates cadastrados
</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Gerencie as mensagens prontas utilizadas nos comentários de tickets.
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-neutral-600">
<Spinner className="size-4" /> Carregando templates...
</div>
) : orderedTemplates.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<IconFileText className="size-5 text-neutral-500" />
</EmptyMedia>
<EmptyTitle>Nenhum template cadastrado</EmptyTitle>
<EmptyDescription>Crie seu primeiro template usando o formulário acima.</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="flex flex-col gap-4">
{orderedTemplates.map((template) => (
<TemplateItem
key={template.id}
template={template}
onSave={handleUpdate}
onDelete={handleDelete}
/>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}
type TemplateItemProps = {
template: {
id: Id<"commentTemplates">
title: string
body: string
updatedAt: number
}
onSave: (templateId: Id<"commentTemplates">, title: string, body: string) => Promise<boolean | void>
onDelete: (templateId: Id<"commentTemplates">) => Promise<void>
}
function TemplateItem({ template, onSave, onDelete }: TemplateItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [title, setTitle] = useState(template.title)
const [body, setBody] = useState(template.body)
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const lastUpdated = useMemo(() => new Date(template.updatedAt), [template.updatedAt])
async function handleSave() {
setIsSaving(true)
const ok = await onSave(template.id, title, body)
setIsSaving(false)
if (ok !== false) {
setIsEditing(false)
}
}
async function handleDelete() {
setIsDeleting(true)
await onDelete(template.id)
setIsDeleting(false)
}
return (
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex flex-col gap-3">
{isEditing ? (
<div className="space-y-3">
<Input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Título" />
<RichTextEditor value={body} onChange={setBody} minHeight={160} />
</div>
) : (
<div className="space-y-2">
<h3 className="text-base font-semibold text-neutral-900">{template.title}</h3>
<RichTextContent html={template.body} className="text-sm text-neutral-700" />
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-neutral-500">
<span>Atualizado em {lastUpdated.toLocaleString("pt-BR")}</span>
<div className="flex items-center gap-2">
{isEditing ? (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setIsEditing(false)
setTitle(template.title)
setBody(template.body)
}}
disabled={isSaving}
>
Cancelar
</Button>
<Button type="button" size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? <Spinner className="size-4 text-white" /> : "Salvar"}
</Button>
</>
) : (
<>
<Button type="button" variant="outline" size="sm" onClick={() => setIsEditing(true)}>
Editar
</Button>
<Button
type="button"
variant="destructive"
size="sm"
className="inline-flex items-center gap-1"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? <Spinner className="size-4 text-white" /> : <IconTrash className="size-4" />}Excluir
</Button>
</>
)}
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,293 @@
"use client"
import { useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Settings2, Share2, ShieldCheck, UserCog, UserPlus, Users2, Layers3, MessageSquareText } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { useAuth, signOut } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { LucideIcon } from "lucide-react"
type RoleRequirement = "admin" | "staff"
type SettingsAction = {
title: string
description: string
href: string
cta: string
requiredRole?: RoleRequirement
icon: LucideIcon
}
const ROLE_LABELS: Record<string, string> = {
admin: "Administrador",
manager: "Gestor",
agent: "Agente",
collaborator: "Colaborador",
customer: "Cliente",
}
const SETTINGS_ACTIONS: SettingsAction[] = [
{
title: "Times & papéis",
description: "Controle quem pode atuar nas filas e atribua permissões refinadas por equipe.",
href: "/admin/teams",
cta: "Gerenciar times",
requiredRole: "admin",
icon: Users2,
},
{
title: "Canais & roteamento",
description: "Configure canais, horários de atendimento e regras automáticas de distribuição.",
href: "/admin/channels",
cta: "Abrir canais",
requiredRole: "admin",
icon: Share2,
},
{
title: "Campos e categorias",
description: "Ajuste categorias, subcategorias e campos personalizados para qualificar tickets.",
href: "/admin/fields",
cta: "Editar estrutura",
requiredRole: "admin",
icon: Layers3,
},
{
title: "Convites e acessos",
description: "Convide novos usuários, revise papéis e acompanhe quem tem acesso ao workspace.",
href: "/admin",
cta: "Abrir painel",
requiredRole: "admin",
icon: UserPlus,
},
{
title: "Templates de comentários",
description: "Gerencie mensagens rápidas utilizadas nos atendimentos.",
href: "/settings/templates",
cta: "Abrir templates",
requiredRole: "staff",
icon: MessageSquareText,
},
{
title: "Preferências da equipe",
description: "Defina padrões de notificação e comportamento do modo play para toda a equipe.",
href: "#preferencias",
cta: "Ajustar preferências",
requiredRole: "staff",
icon: Settings2,
},
{
title: "Políticas e segurança",
description: "Acompanhe SLAs críticos, rastreie integrações e revise auditorias de acesso.",
href: "/admin/slas",
cta: "Revisar SLAs",
requiredRole: "admin",
icon: ShieldCheck,
},
]
export function SettingsContent() {
const { session, isAdmin, isStaff } = useAuth()
const [isSigningOut, setIsSigningOut] = useState(false)
const router = useRouter()
const normalizedRole = session?.user.role?.toLowerCase() ?? "agent"
const roleLabel = ROLE_LABELS[normalizedRole] ?? "Agente"
const tenant = session?.user.tenantId ?? DEFAULT_TENANT_ID
const sessionExpiry = useMemo(() => {
const expiresAt = session?.session?.expiresAt
if (!expiresAt) return null
return new Intl.DateTimeFormat("pt-BR", {
dateStyle: "long",
timeStyle: "short",
}).format(new Date(expiresAt))
}, [session?.session?.expiresAt])
async function handleSignOut() {
if (isSigningOut) return
setIsSigningOut(true)
try {
await signOut()
toast.success("Sessão encerrada")
router.replace("/login")
} catch (error) {
console.error(error)
toast.error("Não foi possível encerrar a sessão.")
} finally {
setIsSigningOut(false)
}
}
function canAccess(requiredRole?: RoleRequirement) {
if (!requiredRole) return true
if (requiredRole === "admin") return isAdmin
if (requiredRole === "staff") return isStaff
return false
}
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 pb-12 lg:px-6">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.7fr)_minmax(0,1fr)] lg:items-start">
<Card id="preferencias" className="border border-border/70">
<CardHeader className="flex flex-col gap-1">
<CardTitle className="text-2xl font-semibold text-neutral-900">Perfil</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Dados sincronizados via Better Auth e utilizados para provisionamento no Convex.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<dl className="grid gap-4 text-sm text-neutral-700 sm:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs uppercase tracking-wide text-neutral-500">Nome</dt>
<dd className="font-medium text-neutral-900">{session?.user.name ?? "—"}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs uppercase tracking-wide text-neutral-500">E-mail</dt>
<dd className="font-medium text-neutral-900">{session?.user.email ?? "—"}</dd>
</div>
<div className="space-y-1">
<dt className="text-xs uppercase tracking-wide text-neutral-500">Tenant</dt>
<dd>
<Badge variant="outline" className="rounded-full border-dashed px-2.5 py-1 text-xs uppercase tracking-wide">
{tenant}
</Badge>
</dd>
</div>
<div className="space-y-1">
<dt className="text-xs uppercase tracking-wide text-neutral-500">Papel</dt>
<dd>
<Badge className="rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide">
{roleLabel}
</Badge>
</dd>
</div>
</dl>
<Separator />
<div className="space-y-2 text-sm text-neutral-600">
<div className="flex items-center justify-between">
<span className="font-medium text-neutral-800">Sessão ativa</span>
{session?.session?.id ? (
<code className="rounded-md bg-slate-100 px-2 py-1 text-xs font-mono text-neutral-700">
{session.session.id.slice(0, 8)}
</code>
) : null}
</div>
<p>{sessionExpiry ? `Expira em ${sessionExpiry}` : "Sessão em background com renovação automática."}</p>
<p className="text-xs text-neutral-500">
Alterações no perfil refletem instantaneamente no painel administrativo e nos relatórios.
</p>
</div>
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled>
Editar perfil (em breve)
</Button>
<Button size="sm" variant="outline" asChild>
<Link href="mailto:suporte@sistema.dev">Solicitar ajustes</Link>
</Button>
<Button size="sm" variant="destructive" onClick={handleSignOut} disabled={isSigningOut}>
Encerrar sessão
</Button>
</CardFooter>
</Card>
<Card className="border border-border/70">
<CardHeader className="flex flex-col gap-1">
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<UserCog className="size-4 text-neutral-500" /> Preferências rápidas
</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Ajustes pessoais aplicados localmente para acelerar seu fluxo de trabalho.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<PreferenceItem
title="Abertura de tickets"
description="Sempre abrir detalhes em nova aba ao clicar na listagem."
/>
<PreferenceItem
title="Notificações"
description="Receber alertas sonoros ao entrar novos tickets urgentes."
/>
<PreferenceItem
title="Modo play"
description="Priorizar tickets da fila 'Chamados' ao iniciar uma nova sessão."
/>
</CardContent>
</Card>
</div>
<section className="space-y-4">
<div>
<h2 className="text-base font-semibold text-neutral-900">Administração do workspace</h2>
<p className="text-sm text-neutral-600">
Centralize a gestão de times, canais e políticas. Recursos marcados como restritos dependem de perfil administrador.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{SETTINGS_ACTIONS.map((action) => {
const allowed = canAccess(action.requiredRole)
const Icon = action.icon
return (
<Card key={action.title} className="border border-border/70">
<CardHeader className="flex flex-row items-start gap-3">
<div className="rounded-full bg-neutral-100 p-2 text-neutral-500">
<Icon className="size-4" />
</div>
<div className="flex flex-1 flex-col gap-1">
<CardTitle className="text-sm font-semibold text-neutral-900">{action.title}</CardTitle>
<CardDescription className="text-xs text-neutral-600">{action.description}</CardDescription>
</div>
{!allowed ? <Badge variant="outline" className="rounded-full border-dashed px-2 py-0.5 text-[10px] uppercase">Restrito</Badge> : null}
</CardHeader>
<CardFooter className="justify-end">
{allowed ? (
<Button asChild size="sm">
<Link href={action.href}>{action.cta}</Link>
</Button>
) : (
<Button size="sm" variant="outline" disabled>
Acesso restrito
</Button>
)}
</CardFooter>
</Card>
)
})}
</div>
</section>
</div>
)
}
type PreferenceItemProps = {
title: string
description: string
}
function PreferenceItem({ title, description }: PreferenceItemProps) {
const [enabled, setEnabled] = useState(false)
return (
<div className="flex items-start justify-between gap-4 rounded-xl border border-dashed border-slate-200/80 bg-white/70 p-4 shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
<div className="space-y-1">
<p className="text-sm font-medium text-neutral-800">{title}</p>
<p className="text-xs text-neutral-500">{description}</p>
</div>
<Button
size="sm"
variant={enabled ? "default" : "outline"}
onClick={() => setEnabled((prev) => !prev)}
className="min-w-[96px]"
>
{enabled ? "Ativado" : "Ativar"}
</Button>
</div>
)
}

View file

@ -0,0 +1,56 @@
import type { ReactNode } from "react"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { SidebarTrigger } from "@/components/ui/sidebar"
interface SiteHeaderProps {
title: string
lead?: string
primaryAction?: ReactNode
secondaryAction?: ReactNode
}
export function SiteHeader({
title,
lead,
primaryAction,
secondaryAction,
}: SiteHeaderProps) {
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-3 border-b bg-background/80 px-6 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height) lg:px-8">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mx-3 hidden h-6 sm:block" />
<div className="flex flex-1 flex-col gap-1">
{lead ? <span className="text-sm text-muted-foreground">{lead}</span> : null}
<h1 className="text-lg font-semibold">{title}</h1>
</div>
<div className="flex items-center gap-2">
{secondaryAction}
{primaryAction}
</div>
</header>
)
}
SiteHeader.PrimaryButton = function SiteHeaderPrimaryButton({
children,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button size="sm" {...props}>
{children}
</Button>
)
}
SiteHeader.SecondaryButton = function SiteHeaderSecondaryButton({
children,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button size="sm" variant="outline" {...props}>
{children}
</Button>
)
}

View file

@ -0,0 +1,139 @@
"use client"
import { useEffect, useMemo } from "react"
import { IconFolders, IconFolder } from "@tabler/icons-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { useTicketCategories } from "@/hooks/use-ticket-categories"
import type { TicketCategory } from "@/lib/schemas/category"
const triggerClass =
"flex h-9 w-full items-center justify-between rounded-lg border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 shadow-sm transition focus:ring-0 data-[state=open]:border-[#00d6eb]"
const contentClass = "rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md"
const itemClass =
"flex items-center justify-between gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
interface CategorySelectProps {
tenantId: string
categoryId: string | null
subcategoryId: string | null
onCategoryChange: (categoryId: string) => void
onSubcategoryChange: (subcategoryId: string) => void
autoSelectFirst?: boolean
disabled?: boolean
categoryLabel?: string
subcategoryLabel?: string
className?: string
secondaryEmptyLabel?: string
layout?: "grid" | "stacked"
}
function findCategory(categories: TicketCategory[], categoryId: string | null) {
if (!categoryId) return null
return categories.find((category) => category.id === categoryId) ?? null
}
export function CategorySelectFields({
tenantId,
categoryId,
subcategoryId,
onCategoryChange,
onSubcategoryChange,
autoSelectFirst = true,
disabled = false,
categoryLabel = "Primária",
subcategoryLabel = "Secundária",
secondaryEmptyLabel = "Selecione uma categoria primária",
className,
layout = "grid",
}: CategorySelectProps) {
const { categories, isLoading } = useTicketCategories(tenantId)
const activeCategory = useMemo(() => findCategory(categories, categoryId), [categories, categoryId])
const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory])
useEffect(() => {
if (!autoSelectFirst || isLoading) return
if (categories.length === 0) return
if (categoryId) return
const first = categories[0]
if (first) {
onCategoryChange(first.id)
const firstSecondary = first.secondary[0]
if (firstSecondary) {
onSubcategoryChange(firstSecondary.id)
}
}
}, [autoSelectFirst, categories, categoryId, isLoading, onCategoryChange, onSubcategoryChange])
useEffect(() => {
if (!categoryId || secondaryOptions.length === 0) return
const stillValid = secondaryOptions.some((item) => item.id === subcategoryId)
if (!stillValid) {
const first = secondaryOptions[0]
if (first) {
onSubcategoryChange(first.id)
}
}
}, [categoryId, secondaryOptions, subcategoryId, onSubcategoryChange])
const containerClass = layout === "stacked" ? "flex flex-col gap-3" : "grid gap-3 sm:grid-cols-2"
return (
<div className={cn(containerClass, className)}>
<div className="space-y-1.5">
<label className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconFolders className="size-3.5" /> {categoryLabel}
</label>
<Select
disabled={disabled || isLoading || categories.length === 0}
value={categoryId ?? undefined}
onValueChange={(value) => {
if (!value) return
onCategoryChange(value)
}}
>
<SelectTrigger className={triggerClass}>
<SelectValue placeholder={isLoading ? "Carregando..." : "Selecione"} />
</SelectTrigger>
<SelectContent className={contentClass}>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id} className={itemClass}>
<span className="flex items-center gap-2">
<IconFolders className="size-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-800">{category.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconFolder className="size-3.5" /> {subcategoryLabel}
</label>
<Select
disabled={disabled || secondaryOptions.length === 0}
value={subcategoryId ?? undefined}
onValueChange={(value) => {
if (!value) return
onSubcategoryChange(value)
}}
>
<SelectTrigger className={triggerClass}>
<SelectValue placeholder={secondaryOptions.length === 0 ? secondaryEmptyLabel : "Selecione"} />
</SelectTrigger>
<SelectContent className={contentClass}>
{secondaryOptions.map((option) => (
<SelectItem key={option.id} value={option.id} className={itemClass}>
<span className="flex items-center gap-2">
<IconFolder className="size-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-800">{option.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}

View file

@ -0,0 +1,64 @@
"use client"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { useMutation } from "convex/react"
// @ts-expect-error Convex runtime API lacks TS declarations until build
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { AlertTriangle, Trash2 } from "lucide-react"
import { toast } from "sonner"
export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) {
const router = useRouter()
const remove = useMutation(api.tickets.remove)
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
async function confirm() {
setLoading(true)
toast.loading("Excluindo ticket...", { id: "del" })
try {
await remove({ ticketId })
toast.success("Ticket excluído.", { id: "del" })
setOpen(false)
router.push("/tickets")
} catch {
toast.error("Não foi possível excluir o ticket.", { id: "del" })
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
size="icon"
aria-label="Excluir ticket"
className="h-9 w-9 rounded-lg border border-transparent bg-transparent text-[#ef4444] transition hover:border-[#fecaca] hover:bg-[#fee2e2] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#fecaca]/50"
>
<Trash2 className="size-4 text-current" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="size-5" /> Excluir ticket
</DialogTitle>
<DialogDescription>
Esta ação é permanente e removerá o ticket, comentários e eventos associados. Deseja continuar?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
<Button variant="destructive" onClick={confirm} disabled={loading} className="gap-2">
{loading ? "Excluindo..." : (<><Trash2 className="size-4" /> Excluir</>)}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,408 @@
"use client"
import { z } from "zod"
import { useEffect, useMemo, useState } from "react"
import type { Doc, Id } from "@/convex/_generated/dataModel"
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
import { useMutation, useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { FieldSet, FieldGroup, Field, FieldLabel, FieldError } from "@/components/ui/field"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Spinner } from "@/components/ui/spinner"
import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import {
PriorityIcon,
priorityStyles,
} from "@/components/tickets/priority-select"
import { CategorySelectFields } from "@/components/tickets/category-select"
import { useDefaultQueues } from "@/hooks/use-default-queues"
const schema = z.object({
subject: z.string().default(""),
summary: z.string().optional(),
description: z.string().default(""),
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
queueName: z.string().nullable().optional(),
assigneeId: z.string().nullable().optional(),
categoryId: z.string().min(1, "Selecione uma categoria"),
subcategoryId: z.string().min(1, "Selecione uma categoria secundária"),
})
export function NewTicketDialog() {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
subject: "",
summary: "",
description: "",
priority: "MEDIUM",
channel: "MANUAL",
queueName: null,
assigneeId: null,
categoryId: "",
subcategoryId: "",
},
mode: "onTouched",
})
const { convexUserId } = useAuth()
const queueArgs = convexUserId
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
: "skip"
useDefaultQueues(DEFAULT_TENANT_ID)
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)
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 priorityValue = form.watch("priority") as TicketPriority
const channelValue = form.watch("channel")
const queueValue = form.watch("queueName") ?? "NONE"
const assigneeValue = form.watch("assigneeId") ?? null
const assigneeSelectValue = assigneeValue ?? "NONE"
const categoryIdValue = form.watch("categoryId")
const subcategoryIdValue = form.watch("subcategoryId")
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 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 previous = form.getValues("categoryId") ?? ""
const next = value ?? ""
form.setValue("categoryId", next, {
shouldDirty: previous !== next && previous !== "",
shouldTouch: true,
shouldValidate: isSubmitted,
})
if (!isSubmitted) {
form.clearErrors("categoryId")
}
}
const handleSubcategoryChange = (value: string) => {
const previous = form.getValues("subcategoryId") ?? ""
const next = value ?? ""
form.setValue("subcategoryId", next, {
shouldDirty: previous !== next && previous !== "",
shouldTouch: true,
shouldValidate: isSubmitted,
})
if (!isSubmitted) {
form.clearErrors("subcategoryId")
}
}
async function submit(values: z.infer<typeof schema>) {
if (!convexUserId) return
const subjectTrimmed = (values.subject ?? "").trim()
if (subjectTrimmed.length < 3) {
form.setError("subject", { type: "min", message: "Informe um assunto com pelo menos 3 caracteres." })
return
}
const sanitizedDescription = sanitizeEditorHtml(values.description ?? "")
const plainDescription = sanitizedDescription.replace(/<[^>]*>/g, "").trim()
if (plainDescription.length === 0) {
form.setError("description", { type: "custom", message: "Descreva o contexto do chamado." })
return
}
setLoading(true)
toast.loading("Criando ticket…", { id: "new-ticket" })
try {
const sel = queues.find((q) => q.name === values.queueName)
const selectedAssignee = form.getValues("assigneeId") ?? null
const id = await create({
actorId: convexUserId as Id<"users">,
tenantId: DEFAULT_TENANT_ID,
subject: subjectTrimmed,
summary: values.summary?.trim() || undefined,
priority: values.priority,
channel: values.channel,
queueId: sel?.id as Id<"queues"> | undefined,
requesterId: convexUserId as Id<"users">,
assigneeId: selectedAssignee ? (selectedAssignee as Id<"users">) : undefined,
categoryId: values.categoryId as Id<"ticketCategories">,
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
})
const summaryFallback = values.summary?.trim() ?? ""
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
if (attachments.length > 0 || bodyHtml.trim().length > 0) {
const typedAttachments = attachments.map((a) => ({
storageId: a.storageId as unknown as Id<"_storage">,
name: a.name,
size: a.size,
type: a.type,
}))
await addComment({ ticketId: id as Id<"tickets">, authorId: convexUserId as Id<"users">, visibility: "PUBLIC", body: bodyHtml, attachments: typedAttachments })
}
toast.success("Ticket criado!", { id: "new-ticket" })
setOpen(false)
form.reset({
subject: "",
summary: "",
description: "",
priority: "MEDIUM",
channel: "MANUAL",
queueName: null,
assigneeId: convexUserId ?? null,
categoryId: "",
subcategoryId: "",
})
form.clearErrors()
setAssigneeInitialized(false)
setAttachments([])
// Navegar para o ticket recém-criado
window.location.href = `/tickets/${id}`
} catch {
toast.error("Não foi possível criar o ticket.", { id: "new-ticket" })
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
size="sm"
className="rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
>
Novo ticket
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl gap-0 overflow-hidden rounded-3xl border border-slate-200 bg-white p-0 shadow-2xl lg:max-w-5xl">
<div className="max-h-[88vh] overflow-y-auto">
<div className="space-y-5 px-6 py-7 sm:px-8 md:px-10">
<form className="space-y-6" onSubmit={form.handleSubmit(submit)}>
<div className="flex flex-col gap-4 border-b border-slate-200 pb-5 md:flex-row md:items-start md:justify-between">
<DialogHeader className="gap-1.5 p-0">
<DialogTitle className="text-xl font-semibold text-neutral-900">Novo ticket</DialogTitle>
<DialogDescription className="text-sm text-neutral-600">
Preencha as informações básicas para abrir um chamado.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end md:min-w-[140px]">
<Button
type="submit"
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30 disabled:opacity-60"
>
{loading ? (
<>
<Spinner className="me-2" /> Criando
</>
) : (
"Criar"
)}
</Button>
</div>
</div>
<FieldSet>
<FieldGroup className="lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<div className="space-y-4">
<Field>
<FieldLabel htmlFor="subject" className="flex items-center gap-1">
Assunto <span className="text-destructive">*</span>
</FieldLabel>
<Input id="subject" {...form.register("subject")} placeholder="Ex.: Erro 500 no portal" />
<FieldError errors={form.formState.errors.subject ? [{ message: form.formState.errors.subject.message }] : []} />
</Field>
<Field>
<FieldLabel htmlFor="summary">Resumo</FieldLabel>
<textarea
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"
{...form.register("summary")}
placeholder="Explique em poucas linhas o contexto do chamado."
/>
</Field>
<Field>
<FieldLabel className="flex items-center gap-1">
Descrição <span className="text-destructive">*</span>
</FieldLabel>
<RichTextEditor
value={form.watch("description") || ""}
onChange={(html) =>
form.setValue("description", html, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: form.formState.isSubmitted,
})
}
placeholder="Detalhe o problema, passos para reproduzir, links, etc."
/>
<FieldError
errors={
form.formState.errors.description
? [{ message: form.formState.errors.description.message }]
: []
}
/>
</Field>
<Field>
<FieldLabel>Anexos</FieldLabel>
<Dropzone
onUploaded={(files) => setAttachments((prev) => [...prev, ...files])}
className="space-y-1.5 [&>div:first-child]:rounded-2xl [&>div:first-child]:p-4 [&>div:first-child]:pb-5 [&>div:first-child]:shadow-sm"
/>
<FieldError className="mt-1">Formatos comuns de imagem e documentos são aceitos.</FieldError>
</Field>
</div>
<div className="space-y-4">
<Field>
<CategorySelectFields
tenantId={DEFAULT_TENANT_ID}
categoryId={categoryIdValue || null}
subcategoryId={subcategoryIdValue || null}
onCategoryChange={handleCategoryChange}
onSubcategoryChange={handleSubcategoryChange}
categoryLabel="Categoria primária *"
subcategoryLabel="Categoria secundária *"
layout="stacked"
/>
{form.formState.errors.categoryId?.message || form.formState.errors.subcategoryId?.message ? (
<FieldError className="mt-1 space-y-0.5">
<>
{form.formState.errors.categoryId?.message ? <div>{form.formState.errors.categoryId?.message}</div> : null}
{form.formState.errors.subcategoryId?.message ? <div>{form.formState.errors.subcategoryId?.message}</div> : null}
</>
</FieldError>
) : null}
</Field>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1 xl:gap-4">
<Field>
<FieldLabel>Prioridade</FieldLabel>
<Select value={priorityValue} onValueChange={(v) => form.setValue("priority", v as z.infer<typeof schema>["priority"])}>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Escolha a prioridade" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => (
<SelectItem key={option} value={option} className={selectItemClass}>
<span className="inline-flex items-center gap-2">
<PriorityIcon value={option} />
{priorityStyles[option].label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Canal</FieldLabel>
<Select value={channelValue} onValueChange={(v) => form.setValue("channel", v as z.infer<typeof schema>["channel"])}>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Canal" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
<SelectItem value="EMAIL" className={selectItemClass}>
E-mail
</SelectItem>
<SelectItem value="WHATSAPP" className={selectItemClass}>
WhatsApp
</SelectItem>
<SelectItem value="CHAT" className={selectItemClass}>
Chat
</SelectItem>
<SelectItem value="PHONE" className={selectItemClass}>
Telefone
</SelectItem>
<SelectItem value="API" className={selectItemClass}>
API
</SelectItem>
<SelectItem value="MANUAL" className={selectItemClass}>
Manual
</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Fila</FieldLabel>
<Select value={queueValue} onValueChange={(v) => form.setValue("queueName", v === "NONE" ? null : v)}>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Sem fila" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
<SelectItem value="NONE" className={selectItemClass}>
Sem fila
</SelectItem>
{queues.map((q) => (
<SelectItem key={q.id} value={q.name} className={selectItemClass}>
{q.name}
</SelectItem>
))}
</SelectContent>
</Select>
</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>
</FieldGroup>
</FieldSet>
</form>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,166 @@
"use client"
import Link from "next/link"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import type { TicketPlayContext, TicketQueueSummary } from "@/lib/schemas/ticket"
import type { Id } from "@/convex/_generated/dataModel"
import { mapTicketFromServer } from "@/lib/mappers/ticket"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { Spinner } from "@/components/ui/spinner"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
interface PlayNextTicketCardProps {
context?: TicketPlayContext
}
const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-neutral-700"
const startButtonClass = "inline-flex items-center gap-2 rounded-lg border border-black bg-[#00e8ff] px-3 py-2 text-sm font-semibold text-black transition hover:bg-[#00d6eb]"
const secondaryButtonClass = "inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-neutral-700 hover:bg-slate-100"
export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
const router = useRouter()
const { convexUserId } = useAuth()
const queueArgs = convexUserId
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
: "skip"
const queueSummary = (
useQuery(convexUserId ? api.queues.summary : "skip", queueArgs) as TicketQueueSummary[] | undefined
) ?? []
const playNext = useMutation(api.tickets.playNext)
const [selectedQueueId, setSelectedQueueId] = useState<string | undefined>(undefined)
const nextTicketFromServer = useQuery(
api.tickets.list,
convexUserId
? {
tenantId: DEFAULT_TENANT_ID,
viewerId: convexUserId as Id<"users">,
status: undefined,
priority: undefined,
channel: undefined,
queueId: (selectedQueueId as Id<"queues">) || undefined,
limit: 1,
}
: "skip"
)?.[0]
const nextTicketUi = nextTicketFromServer ? mapTicketFromServer(nextTicketFromServer as unknown) : null
const cardContext: TicketPlayContext | null =
context ??
(nextTicketUi
? {
queue: {
id: "default",
name: "Geral",
pending: queueSummary.reduce((acc, item) => acc + item.pending, 0),
waiting: queueSummary.reduce((acc, item) => acc + item.waiting, 0),
breached: 0,
},
nextTicket: nextTicketUi,
}
: null)
if (!cardContext || !cardContext.nextTicket) {
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Fila sem tickets pendentes</CardTitle>
</CardHeader>
<CardContent className="text-sm text-neutral-600">
Nenhum ticket disponível no momento. Excelente trabalho!
</CardContent>
</Card>
)
}
const ticket = cardContext.nextTicket
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-lg font-semibold text-neutral-900">
Próximo ticket #{ticket.reference}
</CardTitle>
<TicketPriorityPill priority={ticket.priority} />
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm text-neutral-700">
<div className="flex items-center justify-end gap-2">
<span className="text-xs uppercase tracking-wide text-neutral-500">Fila</span>
<Select value={selectedQueueId ?? "ALL"} onValueChange={(value) => setSelectedQueueId(value === "ALL" ? undefined : value)}>
<SelectTrigger className="h-8 w-[180px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]">
<SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value="ALL">Todas</SelectItem>
{queueSummary.map((queue) => (
<SelectItem key={queue.id} value={queue.id}>
{queue.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<h2 className="text-xl font-semibold text-neutral-900">{ticket.subject}</h2>
<p className="text-sm text-neutral-600">{ticket.summary}</p>
</div>
<div className="flex flex-wrap gap-2 text-xs text-neutral-600">
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
<TicketStatusBadge status={ticket.status} />
<span className="font-medium text-neutral-900">Solicitante: {ticket.requester.name}</span>
</div>
<Separator className="bg-slate-200" />
<div className="flex flex-col gap-3 text-sm text-neutral-700">
<div className="flex items-center justify-between">
<span>Pendentes na fila</span>
<span className="font-semibold text-neutral-900">{cardContext.queue.pending}</span>
</div>
<div className="flex items-center justify-between">
<span>Em espera</span>
<span className="font-semibold text-neutral-900">{cardContext.queue.waiting}</span>
</div>
<div className="flex items-center justify-between">
<span>SLA violado</span>
<span className="font-semibold text-red-600">{cardContext.queue.breached}</span>
</div>
</div>
<Button
className={startButtonClass}
onClick={async () => {
if (!convexUserId) return
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: (selectedQueueId as Id<"queues">) || undefined, agentId: convexUserId as Id<"users"> })
if (chosen?.id) router.push(`/tickets/${chosen.id}`)
}}
>
{convexUserId ? (
<>
<IconPlayerPlayFilled className="size-4 text-black" /> Iniciar atendimento
</>
) : (
<>
<Spinner className="me-2" /> Carregando...
</>
)}
</Button>
<Button variant="ghost" asChild className={secondaryButtonClass}>
<Link href="/tickets">
Ver lista completa
<IconArrowRight className="size-4" />
</Link>
</Button>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,16 @@
import { type TicketPriority } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { PriorityIcon, priorityStyles } from "@/components/tickets/priority-select"
const baseClass = "inline-flex h-9 items-center gap-2 rounded-full px-3 text-sm font-semibold"
export function TicketPriorityPill({ priority }: { priority: TicketPriority }) {
const styles = priorityStyles[priority]
return (
<Badge className={cn(baseClass, styles?.badgeClass)}>
<PriorityIcon value={priority} />
{styles?.label ?? priority}
</Badge>
)
}

View file

@ -0,0 +1,84 @@
"use client"
import { useState } from "react"
import { useMutation } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketPriority } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
export const priorityStyles: Record<TicketPriority, { label: string; badgeClass: string }> = {
LOW: { label: "Baixa", badgeClass: "bg-slate-100 text-slate-700" },
MEDIUM: { label: "Média", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
HIGH: { label: "Alta", badgeClass: "bg-[#fde8d1] text-[#7d3b05]" },
URGENT: { label: "Urgente", badgeClass: "bg-[#fbd9dd] text-[#8b0f1c]" },
}
export const priorityTriggerClass =
"h-8 w-[160px] rounded-full border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
export const priorityItemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
const iconClass = "size-4 text-neutral-700"
export const priorityBadgeClass =
"inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-semibold transition hover:border-slate-300"
const headerTriggerClass =
"group inline-flex h-auto w-auto items-center justify-center rounded-full border border-transparent bg-transparent p-0 shadow-none ring-0 ring-offset-0 ring-offset-transparent focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none hover:bg-transparent data-[state=open]:bg-transparent data-[state=open]:border-transparent data-[state=open]:shadow-none data-[state=open]:ring-0 data-[state=open]:ring-offset-0 data-[state=open]:ring-offset-transparent [&>*:last-child]:hidden"
export function PriorityIcon({ value }: { value: TicketPriority }) {
if (value === "LOW") return <ArrowDown className={iconClass} />
if (value === "MEDIUM") return <ArrowRight className={iconClass} />
if (value === "HIGH") return <ArrowUp className={iconClass} />
return <ChevronsUp className={iconClass} />
}
export function PrioritySelect({ ticketId, value }: { ticketId: string; value: TicketPriority }) {
const updatePriority = useMutation(api.tickets.updatePriority)
const [priority, setPriority] = useState<TicketPriority>(value)
const { convexUserId } = useAuth()
return (
<Select
value={priority}
onValueChange={async (selected) => {
const previous = priority
const next = selected as TicketPriority
setPriority(next)
toast.loading("Atualizando prioridade...", { id: "priority" })
try {
if (!convexUserId) throw new Error("missing user")
await updatePriority({ ticketId: ticketId as unknown as Id<"tickets">, priority: next, actorId: convexUserId as Id<"users"> })
toast.success("Prioridade atualizada!", { id: "priority" })
} catch {
setPriority(previous)
toast.error("Não foi possível atualizar a prioridade.", { id: "priority" })
}
}}
>
<SelectTrigger className={headerTriggerClass} aria-label="Atualizar prioridade">
<SelectValue asChild>
<Badge className={cn(priorityBadgeClass, priorityStyles[priority]?.badgeClass)}>
<PriorityIcon value={priority} />
{priorityStyles[priority]?.label ?? priority}
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
</Badge>
</SelectValue>
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => (
<SelectItem key={option} value={option} className={priorityItemClass}>
<span className="inline-flex items-center gap-2">
<PriorityIcon value={option} />
{priorityStyles[option].label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View file

@ -0,0 +1,165 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TS declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { useAuth } from "@/lib/auth-client"
const metaBadgeClass =
"inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-[11px] font-semibold text-neutral-700"
const channelLabel: Record<string, string> = {
EMAIL: "E-mail",
WHATSAPP: "WhatsApp",
CHAT: "Chat",
PHONE: "Telefone",
API: "API",
MANUAL: "Manual",
}
function TicketRow({ ticket, entering }: { ticket: Ticket; entering: boolean }) {
return (
<div
className={`rounded-xl border border-slate-200 bg-white/60 p-4 transition-all duration-300 hover:border-slate-300 hover:bg-white ${entering ? "recent-ticket-enter" : ""}`}
>
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<span className="text-neutral-900">#{ticket.reference}</span>
<span className="rounded-full bg-[#00e8ff]/15 px-2 py-0.5 text-[11px] font-semibold text-[#006879]">
{ticket.queue ?? "Sem fila"}
</span>
</div>
<div className="space-y-1">
<Link href={`/tickets/${ticket.id}`} className="line-clamp-1 text-base font-semibold text-neutral-900 transition hover:text-neutral-700">
{ticket.subject}
</Link>
<p className="line-clamp-2 text-sm text-neutral-600">{ticket.summary ?? "Sem resumo"}</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-600">
<span className="font-semibold text-neutral-700">{ticket.requester.name}</span>
<span className="text-neutral-400"></span>
<span>{formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })}</span>
</div>
{ticket.category ? (
<Badge className="inline-flex items-center gap-1 rounded-full border border-[#00e8ff]/50 bg-[#00e8ff]/10 px-3 py-1 text-[11px] font-semibold text-[#02414d]">
{ticket.category.name}
{ticket.subcategory ? `${ticket.subcategory.name}` : ""}
</Badge>
) : (
<Badge className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-[11px] font-semibold text-neutral-700">
Sem categoria
</Badge>
)}
</div>
<div className="flex shrink-0 flex-col gap-3 text-right md:w-[220px]">
<div className="flex flex-wrap justify-end gap-2">
<TicketStatusBadge status={ticket.status} />
<TicketPriorityPill priority={ticket.priority} />
</div>
<div className="flex flex-wrap justify-end gap-x-2 gap-y-1 text-xs text-neutral-600">
<Badge className={metaBadgeClass}>{channelLabel[ticket.channel] ?? ticket.channel}</Badge>
<Badge className={metaBadgeClass}>{ticket.assignee?.name ?? "Sem responsável"}</Badge>
</div>
</div>
</div>
</div>
)
}
export function RecentTicketsPanel() {
const { convexUserId } = useAuth()
const ticketsRaw = useQuery(
api.tickets.list,
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users">, limit: 6 } : "skip"
)
const [enteringId, setEnteringId] = useState<string | null>(null)
const previousIdsRef = useRef<string[]>([])
const tickets = useMemo(
() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]).slice(0, 6),
[ticketsRaw]
)
useEffect(() => {
if (ticketsRaw === undefined) {
previousIdsRef.current = []
return
}
const ids = tickets.map((ticket) => ticket.id)
const previous = previousIdsRef.current
if (!ids.length) {
previousIdsRef.current = ids
return
}
if (!previous.length) {
previousIdsRef.current = ids
return
}
const topId = ids[0]
if (!previous.includes(topId)) {
setEnteringId(topId)
}
previousIdsRef.current = ids
}, [tickets, ticketsRaw])
useEffect(() => {
if (!enteringId) return
const timer = window.setTimeout(() => setEnteringId(null), 600)
return () => window.clearTimeout(timer)
}, [enteringId])
if (ticketsRaw === undefined) {
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base font-semibold text-neutral-900">Últimos chamados</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="rounded-xl border border-slate-100 bg-slate-50/60 p-4">
<Skeleton className="mb-2 h-4 w-48" />
<Skeleton className="h-3 w-64" />
</div>
))}
</CardContent>
</Card>
)
}
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="flex flex-row items-center justify-between pb-4">
<CardTitle className="text-base font-semibold text-neutral-900">Últimos chamados</CardTitle>
<Button asChild size="sm" variant="ghost" className="text-sm font-semibold text-[#006879] hover:bg-[#00e8ff]/10">
<Link href="/tickets">Ver todos</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3">
{tickets.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50/80 p-6 text-center text-sm text-neutral-600">
Nenhum ticket recente encontrado.
</div>
) : (
tickets.map((ticket) => (
<TicketRow key={ticket.id} ticket={ticket} entering={ticket.id === enteringId} />
))
)}
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,26 @@
"use client"
import { ticketStatusSchema, type TicketStatus } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
const statusStyles: Record<TicketStatus, { label: string; className: string }> = {
NEW: { label: "Novo", className: "border border-slate-200 bg-slate-100 text-slate-700" },
OPEN: { label: "Aberto", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
PENDING: { label: "Pendente", className: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" },
ON_HOLD: { label: "Em espera", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
RESOLVED: { label: "Resolvido", className: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
CLOSED: { label: "Fechado", className: "border border-slate-200 bg-slate-200 text-slate-700" },
}
type TicketStatusBadgeProps = { status: TicketStatus }
export function TicketStatusBadge({ status }: TicketStatusBadgeProps) {
const parsed = ticketStatusSchema.parse(status)
const styles = statusStyles[parsed]
return (
<Badge className={cn('inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold', styles?.className)}>
{styles?.label ?? parsed}
</Badge>
)
}

View file

@ -0,0 +1,71 @@
"use client"
import { useState } from "react"
import { useMutation } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketStatus } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
import { ChevronDown } from "lucide-react"
const statusStyles: Record<TicketStatus, { label: string; badgeClass: string }> = {
NEW: { label: "Novo", badgeClass: "bg-slate-100 text-slate-700" },
OPEN: { label: "Aberto", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
PENDING: { label: "Pendente", badgeClass: "bg-[#fdebd6] text-[#7b4107]" },
ON_HOLD: { label: "Em espera", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
RESOLVED: { label: "Resolvido", badgeClass: "bg-[#dcf4eb] text-[#1f6a45]" },
CLOSED: { label: "Fechado", badgeClass: "bg-slate-200 text-slate-700" },
}
const triggerClass =
"group inline-flex h-auto w-auto items-center justify-center rounded-full border border-transparent bg-transparent p-0 shadow-none ring-0 ring-offset-0 ring-offset-transparent focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none hover:bg-transparent data-[state=open]:bg-transparent data-[state=open]:border-transparent data-[state=open]:shadow-none data-[state=open]:ring-0 data-[state=open]:ring-offset-0 data-[state=open]:ring-offset-transparent [&>*:last-child]:hidden"
const itemClass = "rounded-md px-2 py-2 text-sm text-neutral-800 transition hover:bg-slate-100 data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
const baseBadgeClass =
"inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-semibold transition hover:border-slate-300"
export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) {
const updateStatus = useMutation(api.tickets.updateStatus)
const [status, setStatus] = useState<TicketStatus>(value)
const { convexUserId } = useAuth()
return (
<Select
value={status}
onValueChange={async (selected) => {
const previous = status
const next = selected as TicketStatus
setStatus(next)
toast.loading("Atualizando status...", { id: "status" })
try {
if (!convexUserId) throw new Error("missing user")
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId: convexUserId as Id<"users"> })
toast.success("Status alterado para " + (statusStyles[next]?.label ?? next) + ".", { id: "status" })
} catch {
setStatus(previous)
toast.error("Não foi possível atualizar o status.", { id: "status" })
}
}}
>
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
<SelectValue asChild>
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass)}>
{statusStyles[status]?.label ?? status}
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
</Badge>
</SelectValue>
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{(["NEW", "OPEN", "PENDING", "ON_HOLD", "RESOLVED", "CLOSED"] as const).map((option) => (
<SelectItem key={option} value={option} className={itemClass}>
{statusStyles[option].label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View file

@ -0,0 +1,626 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react"
import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
import { useAction, useMutation, useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Spinner } from "@/components/ui/spinner"
interface TicketCommentsProps {
ticket: TicketWithDetails
}
const badgeInternal = "gap-1 rounded-full border border-slate-300 bg-neutral-900 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-white"
const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const submitButtonClass =
"inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
export function TicketComments({ ticket }: TicketCommentsProps) {
const { convexUserId, isStaff, role } = useAuth()
const isManager = role === "manager"
const addComment = useMutation(api.tickets.addComment)
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
const updateComment = useMutation(api.tickets.updateComment)
const [body, setBody] = useState("")
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
const [preview, setPreview] = useState<string | null>(null)
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id" | "author" | "visibility" | "body" | "attachments" | "createdAt" | "updatedAt">[]>([])
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
const [attachmentToRemove, setAttachmentToRemove] = useState<{ commentId: string; attachmentId: string; name: string } | null>(null)
const [removingAttachment, setRemovingAttachment] = useState(false)
const [editingComment, setEditingComment] = useState<{ id: string; value: string } | null>(null)
const [savingCommentId, setSavingCommentId] = useState<string | null>(null)
const [localBodies, setLocalBodies] = useState<Record<string, string>>({})
const templateArgs = convexUserId && isStaff
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> }
: "skip"
const templatesResult = useQuery(convexUserId && isStaff ? api.commentTemplates.list : "skip", templateArgs) as
| { id: string; title: string; body: string }[]
| undefined
const templates = templatesResult ?? []
const templatesLoading = Boolean(convexUserId && isStaff) && templatesResult === undefined
const canUseTemplates = Boolean(convexUserId && isStaff)
const insertTemplateIntoBody = (html: string) => {
const sanitized = sanitizeEditorHtml(html)
setBody((current) => {
if (!current) return sanitized
const merged = `${current}<p><br /></p>${sanitized}`
return sanitizeEditorHtml(merged)
})
}
const startEditingComment = useCallback((commentId: string, currentBody: string) => {
setEditingComment({ id: commentId, value: currentBody || "" })
}, [])
const cancelEditingComment = useCallback(() => {
setEditingComment(null)
}, [])
const saveEditedComment = useCallback(
async (commentId: string, originalBody: string) => {
if (!editingComment || editingComment.id !== commentId) return
if (!convexUserId) return
if (commentId.startsWith("temp-")) return
const sanitized = sanitizeEditorHtml(editingComment.value)
if (sanitized === originalBody) {
setEditingComment(null)
return
}
const toastId = `edit-comment-${commentId}`
setSavingCommentId(commentId)
toast.loading("Salvando comentário...", { id: toastId })
try {
await updateComment({
ticketId: ticket.id as Id<"tickets">,
commentId: commentId as unknown as Id<"ticketComments">,
actorId: convexUserId as Id<"users">,
body: sanitized,
})
setLocalBodies((prev) => ({ ...prev, [commentId]: sanitized }))
setEditingComment(null)
toast.success("Comentário atualizado!", { id: toastId })
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o comentário.", { id: toastId })
} finally {
setSavingCommentId(null)
}
},
[editingComment, ticket.id, updateComment, convexUserId]
)
const commentsAll = useMemo(() => {
return [...pending, ...ticket.comments]
}, [pending, ticket.comments])
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!convexUserId) return
const now = new Date()
const selectedVisibility = isManager ? "PUBLIC" : visibility
const attachments = attachmentsToSend.map((item) => ({ ...item }))
const previewsToRevoke = attachments
.map((attachment) => attachment.previewUrl)
.filter((previewUrl): previewUrl is string => Boolean(previewUrl && previewUrl.startsWith("blob:")))
const optimistic = {
id: `temp-${now.getTime()}`,
author: ticket.requester,
visibility: selectedVisibility,
body: sanitizeEditorHtml(body),
attachments: attachments.map((attachment) => ({
id: attachment.storageId,
name: attachment.name,
type: attachment.type,
url: attachment.previewUrl,
})),
createdAt: now,
updatedAt: now,
}
setPending((current) => [optimistic, ...current])
setBody("")
setAttachmentsToSend([])
toast.loading("Enviando comentário...", { id: "comment" })
try {
const payload = attachments.map((attachment) => ({
storageId: attachment.storageId as unknown as Id<"_storage">,
name: attachment.name,
size: attachment.size,
type: attachment.type,
}))
await addComment({
ticketId: ticket.id as Id<"tickets">,
authorId: convexUserId as Id<"users">,
visibility: selectedVisibility,
body: optimistic.body,
attachments: payload,
})
setPending([])
toast.success("Comentário enviado!", { id: "comment" })
} catch {
setPending([])
toast.error("Falha ao enviar comentário.", { id: "comment" })
}
previewsToRevoke.forEach((previewUrl) => {
try {
URL.revokeObjectURL(previewUrl)
} catch (error) {
console.error("Failed to revoke preview URL", error)
}
})
}
async function handleRemoveAttachment() {
if (!attachmentToRemove || !convexUserId) return
setRemovingAttachment(true)
toast.loading("Removendo anexo...", { id: "remove-attachment" })
try {
await removeAttachment({
ticketId: ticket.id as unknown as Id<"tickets">,
commentId: attachmentToRemove.commentId as Id<"ticketComments">,
attachmentId: attachmentToRemove.attachmentId as Id<"_storage">,
actorId: convexUserId as Id<"users">,
})
toast.success("Anexo removido.", { id: "remove-attachment" })
setAttachmentToRemove(null)
} catch (error) {
console.error(error)
toast.error("Não foi possível remover o anexo.", { id: "remove-attachment" })
} finally {
setRemovingAttachment(false)
}
}
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-4 pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<IconMessage className="size-5 text-neutral-900" /> Comentários
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-4 pb-6">
{commentsAll.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<IconMessage className="size-5 text-neutral-900" />
</EmptyMedia>
<EmptyTitle className="text-neutral-900">Nenhum comentário ainda</EmptyTitle>
<EmptyDescription className="text-neutral-600">Registre o próximo passo abaixo.</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
commentsAll.map((comment) => {
const initials = comment.author.name
.split(" ")
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("")
const commentId = String(comment.id)
const storedBody = localBodies[commentId] ?? comment.body ?? ""
const bodyPlain = storedBody.replace(/<[^>]*>/g, "").trim()
const isEditing = editingComment?.id === commentId
const isPending = commentId.startsWith("temp-")
const canEdit = Boolean(convexUserId && String(comment.author.id) === convexUserId && !isPending)
const hasBody = bodyPlain.length > 0 || isEditing
return (
<div key={comment.id} className="group/comment flex gap-3">
<Avatar className="size-9 border border-slate-200">
<AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-neutral-900">{comment.author.name}</span>
{comment.visibility === "INTERNAL" ? (
<Badge className={badgeInternal}>
<IconLock className="size-3 text-[#00e8ff]" /> Interno
</Badge>
) : null}
<span className="text-xs text-neutral-500">
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span>
</div>
{isEditing ? (
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2">
<RichTextEditor
value={editingComment?.value ?? ""}
onChange={(next) =>
setEditingComment((prev) => (prev && prev.id === commentId ? { ...prev, value: next } : prev))
}
disabled={savingCommentId === commentId}
placeholder="Edite o comentário..."
/>
<div className="mt-3 flex items-center justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100"
onClick={cancelEditingComment}
disabled={savingCommentId === commentId}
>
Cancelar
</Button>
<Button
type="button"
className={submitButtonClass}
onClick={() => saveEditedComment(commentId, storedBody)}
disabled={savingCommentId === commentId}
>
{savingCommentId === commentId ? <Spinner className="size-4 text-white" /> : "Salvar"}
</Button>
</div>
</div>
) : hasBody ? (
<div className="relative break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
{canEdit ? (
<button
type="button"
onClick={() => startEditingComment(commentId, storedBody)}
className="absolute right-2 top-2 inline-flex size-7 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 group-hover/comment:opacity-100"
aria-label="Editar comentário"
>
<PencilLine className="size-3.5" />
</button>
) : null}
<RichTextContent html={storedBody} />
</div>
) : canEdit ? (
<div className="rounded-xl border border-dashed border-slate-300 bg-white/60 px-3 py-2 text-sm text-neutral-500">
<button
type="button"
onClick={() => startEditingComment(commentId, storedBody)}
className="inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition hover:text-neutral-900"
>
<PencilLine className="size-4" />
Adicionar conteúdo ao comentário
</button>
</div>
) : null}
{comment.attachments?.length ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{comment.attachments.map((attachment) => (
<CommentAttachmentCard
key={attachment.id}
attachment={attachment}
onOpenPreview={(url) => setPreview(url)}
onRequestRemoval={() =>
setAttachmentToRemove({ commentId: comment.id, attachmentId: attachment.id, name: attachment.name })
}
/>
))}
</div>
) : null}
</div>
</div>
)
})
)}
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
{attachmentsToSend.length > 0 ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{attachmentsToSend.map((attachment, index) => {
const name = attachment.name
const previewUrl = attachment.previewUrl
const isImage =
(attachment.type ?? "").startsWith("image/") ||
/\.(png|jpe?g|gif|webp|svg)$/i.test(name)
return (
<div key={`${attachment.storageId}-${index}`} className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5">
{isImage && previewUrl ? (
<button
type="button"
onClick={() => setPreview(previewUrl || null)}
className="block w-full overflow-hidden rounded-md"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={previewUrl} alt={name} className="h-24 w-full rounded-md object-cover" />
</button>
) : (
<div className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-600">
<FileIcon className="size-4" />
<span className="line-clamp-2 px-2 text-center">{name}</span>
</div>
)}
<button
type="button"
onClick={() =>
setAttachmentsToSend((prev) => {
const next = [...prev]
const removed = next.splice(index, 1)[0]
if (removed?.previewUrl?.startsWith("blob:")) {
URL.revokeObjectURL(removed.previewUrl)
}
return next
})
}
className="absolute right-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30"
aria-label={`Remover ${name}`}
>
<X className="size-3.5" />
</button>
<div className="mt-1 line-clamp-1 w-full text-ellipsis text-center text-[11px] text-neutral-500">
{name}
</div>
</div>
)
})}
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3 text-xs text-neutral-600">
{canUseTemplates ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="inline-flex items-center gap-2 border-slate-200 text-sm text-neutral-700 hover:bg-slate-50"
disabled={templatesLoading}
>
<IconFileText className="size-4" />
Inserir template
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{templatesLoading ? (
<div className="flex items-center gap-2 px-3 py-2 text-sm text-neutral-500">
<Spinner className="size-4" />
Carregando templates...
</div>
) : templates.length === 0 ? (
<div className="px-3 py-2 text-sm text-neutral-500">
Nenhum template disponível. Cadastre novos em configurações.
</div>
) : (
templates.map((template) => (
<DropdownMenuItem
key={template.id}
className="flex flex-col items-start whitespace-normal py-2"
onSelect={() => insertTemplateIntoBody(template.body)}
>
<span className="text-sm font-medium text-neutral-800">{template.title}</span>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<div className="flex items-center gap-2">
Visibilidade:
<Select
value={visibility}
onValueChange={(value) => {
if (isManager) return
setVisibility(value as "PUBLIC" | "INTERNAL")
}}
disabled={isManager}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Visibilidade" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value="PUBLIC">Pública</SelectItem>
{!isManager ? <SelectItem value="INTERNAL">Interna</SelectItem> : null}
</SelectContent>
</Select>
</div>
</div>
<Button type="submit" size="sm" className={submitButtonClass}>
Enviar
</Button>
</div>
</form>
<Dialog open={!!attachmentToRemove} onOpenChange={(open) => { if (!open && !removingAttachment) setAttachmentToRemove(null) }}>
<DialogContent className="max-w-sm space-y-4">
<DialogHeader>
<DialogTitle>Remover anexo</DialogTitle>
<DialogDescription>
Tem certeza de que deseja remover "{attachmentToRemove?.name}" deste comentário?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setAttachmentToRemove(null)}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100"
disabled={removingAttachment}
>
Cancelar
</Button>
<Button
type="button"
onClick={handleRemoveAttachment}
disabled={removingAttachment}
className="inline-flex items-center gap-2 rounded-lg border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30 disabled:opacity-60"
>
<Trash2 className="size-4" />
{removingAttachment ? "Removendo..." : "Excluir"}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
<DialogContent className="max-w-3xl p-0">
<DialogHeader className="sr-only">
<DialogTitle>Visualização de anexo</DialogTitle>
</DialogHeader>
{preview ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={preview} alt="Preview" className="h-auto w-full rounded-xl" />
</>
) : null}
</DialogContent>
</Dialog>
</CardContent>
</Card>
)
}
type CommentAttachment = TicketWithDetails["comments"][number]["attachments"][number]
function CommentAttachmentCard({
attachment,
onOpenPreview,
onRequestRemoval,
}: {
attachment: CommentAttachment
onOpenPreview: (url: string) => void
onRequestRemoval: () => void
}) {
const getFileUrl = useAction(api.files.getUrl)
const [url, setUrl] = useState<string | null>(attachment.url ?? null)
const [refreshing, setRefreshing] = useState(false)
const [errored, setErrored] = useState(false)
const hasRefreshedRef = useRef(false)
const isImageType = useMemo(() => {
const name = attachment.name ?? ""
const type = attachment.type ?? ""
return (!!type && type.startsWith("image/")) || /\.(png|jpe?g|gif|webp|svg)$/i.test(name)
}, [attachment.name, attachment.type])
useEffect(() => {
setUrl(attachment.url ?? null)
setErrored(false)
hasRefreshedRef.current = false
}, [attachment.id, attachment.url])
const ensureUrl = useCallback(async () => {
try {
setRefreshing(true)
const fresh = await getFileUrl({ storageId: attachment.id as Id<"_storage"> })
if (fresh) {
setUrl(fresh)
setErrored(false)
return fresh
}
} catch (error) {
console.error("Failed to refresh attachment URL", error)
} finally {
setRefreshing(false)
}
return null
}, [attachment.id, getFileUrl])
useEffect(() => {
let cancelled = false
;(async () => {
const fresh = await ensureUrl()
if (!cancelled && fresh) {
setUrl(fresh)
}
})()
return () => {
cancelled = true
}
}, [ensureUrl])
const handleImageError = useCallback(async () => {
if (hasRefreshedRef.current) {
setErrored(true)
return
}
hasRefreshedRef.current = true
const fresh = await ensureUrl()
if (!fresh) {
setErrored(true)
}
}, [ensureUrl])
const handlePreview = useCallback(async () => {
const target = url ?? (await ensureUrl())
if (target) {
onOpenPreview(target)
}
}, [ensureUrl, onOpenPreview, url])
const handleDownload = useCallback(async () => {
const target = url ?? (await ensureUrl())
if (target) {
window.open(target, "_blank", "noopener,noreferrer")
}
}, [ensureUrl, url])
const name = attachment.name ?? ""
const urlLooksImage = url ? /\.(png|jpe?g|gif|webp|svg)$/i.test(url) : false
const showImage = isImageType && url && !errored
return (
<div className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
{showImage ? (
<button type="button" onClick={handlePreview} className="relative block w-full overflow-hidden rounded-md">
{refreshing ? (
<div className="absolute inset-0 flex items-center justify-center bg-white/70">
<Spinner className="size-5 text-neutral-600" />
</div>
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
key={url ?? "no-url"}
src={url ?? undefined}
alt={name}
className="h-24 w-full rounded-md object-cover"
onError={handleImageError}
/>
</button>
) : (
<button
type="button"
onClick={isImageType || urlLooksImage ? handlePreview : handleDownload}
className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-700 transition hover:bg-slate-100"
disabled={refreshing}
>
{refreshing ? (
<Spinner className="size-5 text-neutral-600" />
) : isImageType || urlLooksImage ? (
<ImageIcon className="size-5 text-neutral-600" />
) : (
<FileIcon className="size-5 text-neutral-600" />
)}
<span className="font-medium">
{errored ? "Não foi possível carregar" : refreshing ? "Gerando link..." : url ? "Abrir" : "Gerar link"}
</span>
</button>
)}
<button
type="button"
onClick={onRequestRemoval}
aria-label={`Remover ${name}`}
className="absolute right-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white opacity-0 transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30 focus-visible:opacity-100 group-hover:opacity-100"
>
<X className="size-3.5" />
</button>
<div className="mt-1 line-clamp-1 w-full text-ellipsis text-center text-[11px] text-neutral-500">{name}</div>
</div>
)
}

View file

@ -0,0 +1,21 @@
"use client";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
import type { TicketWithDetails } from "@/lib/schemas/ticket";
export function TicketDetailStatic({ ticket }: { ticket: TicketWithDetails }) {
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSummaryHeader ticket={ticket} />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<TicketTimeline ticket={ticket} />
</div>
<TicketDetailsPanel ticket={ticket} />
</div>
</div>
);
}

View file

@ -0,0 +1,79 @@
"use client";
import { useQuery } from "convex/react";
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
import type { Id } from "@/convex/_generated/dataModel";
import type { TicketWithDetails } from "@/lib/schemas/ticket";
import { getTicketById } from "@/lib/mocks/tickets";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { TicketComments } from "@/components/tickets/ticket-comments.rich";
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
import { useAuth } from "@/lib/auth-client";
export function TicketDetailView({ id }: { id: string }) {
const isMockId = id.startsWith("ticket-");
const { convexUserId } = useAuth();
const shouldSkip = isMockId || !convexUserId;
const t = useQuery(
api.tickets.getById,
shouldSkip
? "skip"
: {
tenantId: DEFAULT_TENANT_ID,
id: id as Id<"tickets">,
viewerId: convexUserId as Id<"users">,
}
);
let ticket: TicketWithDetails | null = null;
if (t) {
ticket = mapTicketWithDetailsFromServer(t as unknown);
} else if (isMockId) {
ticket = getTicketById(id) ?? null;
}
if (!ticket) return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-3 p-6">
<div className="flex items-center gap-2"><Skeleton className="h-5 w-24" /><Skeleton className="h-5 w-20" /></div>
<Skeleton className="h-7 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-4 p-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="flex items-center gap-2"><Skeleton className="h-4 w-28" /><Skeleton className="h-3 w-24" /></div>
<Skeleton className="h-16 w-full" />
</div>
))}
</CardContent>
</Card>
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-3 p-6">
{Array.from({ length: 5 }).map((_, i) => (<Skeleton key={i} className="h-3 w-full" />))}
</CardContent>
</Card>
</div>
</div>
);
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSummaryHeader ticket={ticket} />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<TicketComments ticket={ticket} />
<TicketTimeline ticket={ticket} />
</div>
<TicketDetailsPanel ticket={ticket} />
</div>
</div>
);
}

View file

@ -0,0 +1,93 @@
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconAlertTriangle, IconClockHour4, IconTags } from "@tabler/icons-react"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
interface TicketDetailsPanelProps {
ticket: TicketWithDetails
}
const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-neutral-700"
const tagBadgeClass = "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-neutral-700"
const iconAccentClass = "size-3 text-neutral-700"
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-4 pb-3">
<CardTitle className="text-lg font-semibold text-neutral-900">Detalhes</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5 px-4 pb-6 text-sm text-neutral-700">
<div className="space-y-1 break-words">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Fila</p>
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
</div>
<Separator className="bg-slate-200" />
<div className="space-y-2 break-words">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">SLA</p>
{ticket.slaPolicy ? (
<div className="flex flex-col gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-sm">
<span className="text-sm font-semibold text-neutral-900">{ticket.slaPolicy.name}</span>
<div className="flex flex-col gap-1 text-xs text-neutral-600">
{ticket.slaPolicy.targetMinutesToFirstResponse ? (
<span>Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min</span>
) : null}
{ticket.slaPolicy.targetMinutesToResolution ? (
<span>Resolução: {ticket.slaPolicy.targetMinutesToResolution} min</span>
) : null}
</div>
</div>
) : (
<span className="text-neutral-600">Sem política atribuída.</span>
)}
</div>
<Separator className="bg-slate-200" />
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Métricas</p>
{ticket.metrics ? (
<div className="flex flex-col gap-2 text-xs text-neutral-700">
<span className="flex items-center gap-2">
<IconClockHour4 className={iconAccentClass} /> Tempo aguardando: {ticket.metrics.timeWaitingMinutes ?? "-"} min
</span>
<span className="flex items-center gap-2">
<IconAlertTriangle className={iconAccentClass} /> Tempo aberto: {ticket.metrics.timeOpenedMinutes ?? "-"} min
</span>
</div>
) : (
<span className="text-neutral-600">Sem dados de SLA ainda.</span>
)}
</div>
<Separator className="bg-slate-200" />
<div className="space-y-2 break-words">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tags</p>
<div className="flex flex-wrap gap-2">
{ticket.tags?.length ? (
ticket.tags.map((tag) => (
<Badge key={tag} className={tagBadgeClass}>
<IconTags className={iconAccentClass} /> {tag}
</Badge>
))
) : (
<span className="text-neutral-600">Sem tags.</span>
)}
</div>
</div>
<Separator className="bg-slate-200" />
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Histórico</p>
<div className="flex flex-col gap-1 text-xs text-neutral-600">
<span>Criado: {format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
<span>Atualizado: {format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
{ticket.resolvedAt ? (
<span>Resolvido: {format(ticket.resolvedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
) : null}
</div>
</div>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,74 @@
"use client"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
interface TicketQueueSummaryProps {
queues?: TicketQueueSummary[]
}
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
const { convexUserId } = useAuth()
const queueArgs = convexUserId
? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> }
: "skip"
const fromServer = useQuery(convexUserId ? api.queues.summary : "skip", queueArgs)
const data: TicketQueueSummary[] = (queues ?? (fromServer as TicketQueueSummary[] | undefined) ?? [])
if (!queues && fromServer === undefined) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
<div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" />
</div>
))}
</div>
)
}
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{data.map((queue) => {
const total = queue.pending + queue.waiting
const breachPercent = total === 0 ? 0 : Math.round((queue.breached / total) * 100)
return (
<Card key={queue.id} className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<CardHeader className="pb-2">
<CardDescription className="text-xs uppercase tracking-wide text-neutral-500">Fila</CardDescription>
<CardTitle className="text-lg font-semibold text-neutral-900">{queue.name}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm text-neutral-600">
<div className="flex justify-between">
<span>Pendentes</span>
<span className="font-semibold text-neutral-900">{queue.pending}</span>
</div>
<div className="flex justify-between">
<span>Aguardando resposta</span>
<span className="font-semibold text-neutral-900">{queue.waiting}</span>
</div>
<div className="flex items-center justify-between">
<span>Violados</span>
<span className="font-semibold text-red-600">{queue.breached}</span>
</div>
<div className="pt-1.5">
<Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
<span className="mt-2 block text-xs text-neutral-500">
{breachPercent}% com SLA violado nesta fila
</span>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}

View file

@ -0,0 +1,544 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
// @ts-expect-error Convex generates JS module without TS definitions
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import type { Doc, Id } from "@/convex/_generated/dataModel"
import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { PrioritySelect } from "@/components/tickets/priority-select"
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
import { StatusSelect } from "@/components/tickets/status-select"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useTicketCategories } from "@/hooks/use-ticket-categories"
import { useDefaultQueues } from "@/hooks/use-default-queues"
interface TicketHeaderProps {
ticket: TicketWithDetails
}
const cardClass = "relative space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
const referenceBadgeClass = "inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700"
const startButtonClass =
"inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30"
const pauseButtonClass =
"inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
const editButtonClass =
"inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
const selectTriggerClass = "h-8 w-full rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const smallSelectTriggerClass = "h-8 w-full rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const sectionLabelClass = "text-xs font-semibold uppercase tracking-wide text-neutral-500"
const sectionValueClass = "font-medium text-neutral-900"
const subtleBadgeClass =
"inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-[11px] font-medium text-neutral-600"
const EMPTY_CATEGORY_VALUE = "__none__"
const EMPTY_SUBCATEGORY_VALUE = "__none__"
function formatDuration(durationMs: number) {
if (durationMs <= 0) return "0s"
const totalSeconds = Math.floor(durationMs / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
}
if (minutes > 0) {
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
}
return `${seconds}s`
}
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const { convexUserId, role } = useAuth()
const isManager = role === "manager"
useDefaultQueues(ticket.tenantId)
const changeAssignee = useMutation(api.tickets.changeAssignee)
const changeQueue = useMutation(api.tickets.changeQueue)
const updateSubject = useMutation(api.tickets.updateSubject)
const updateSummary = useMutation(api.tickets.updateSummary)
const startWork = useMutation(api.tickets.startWork)
const pauseWork = useMutation(api.tickets.pauseWork)
const updateCategories = useMutation(api.tickets.updateCategories)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const queueArgs = convexUserId
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> }
: "skip"
const queues = (
useQuery(convexUserId ? api.queues.summary : "skip", queueArgs) as TicketQueueSummary[] | undefined
) ?? []
const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId)
const [status] = useState<TicketStatus>(ticket.status)
const workSummaryRemote = useQuery(
api.tickets.workSummary,
convexUserId
? { ticketId: ticket.id as Id<"tickets">, viewerId: convexUserId as Id<"users"> }
: "skip"
) as
| {
ticketId: Id<"tickets">
totalWorkedMs: number
activeSession: { id: Id<"ticketWorkSessions">; agentId: Id<"users">; startedAt: number } | null
}
| null
| undefined
const [editing, setEditing] = useState(false)
const [subject, setSubject] = useState(ticket.subject)
const [summary, setSummary] = useState(ticket.summary ?? "")
const [categorySelection, setCategorySelection] = useState<{ categoryId: string; subcategoryId: string }>(
{
categoryId: ticket.category?.id ?? "",
subcategoryId: ticket.subcategory?.id ?? "",
}
)
const [saving, setSaving] = useState(false)
const selectedCategoryId = categorySelection.categoryId
const selectedSubcategoryId = categorySelection.subcategoryId
const dirty = useMemo(
() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""),
[subject, summary, ticket.subject, ticket.summary]
)
const currentCategoryId = ticket.category?.id ?? ""
const currentSubcategoryId = ticket.subcategory?.id ?? ""
const categoryDirty = useMemo(() => {
return selectedCategoryId !== currentCategoryId || selectedSubcategoryId !== currentSubcategoryId
}, [selectedCategoryId, selectedSubcategoryId, currentCategoryId, currentSubcategoryId])
const currentQueueName = ticket.queue ?? ""
const [queueSelection, setQueueSelection] = useState(currentQueueName)
const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, currentQueueName])
const formDirty = dirty || categoryDirty || queueDirty
const activeCategory = useMemo(
() => categories.find((category) => category.id === selectedCategoryId) ?? null,
[categories, selectedCategoryId]
)
const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory])
async function handleSave() {
if (!convexUserId || !formDirty) {
setEditing(false)
return
}
setSaving(true)
try {
if (categoryDirty && !isManager) {
toast.loading("Atualizando categoria...", { id: "ticket-category" })
try {
await updateCategories({
ticketId: ticket.id as Id<"tickets">,
categoryId: selectedCategoryId ? (selectedCategoryId as Id<"ticketCategories">) : null,
subcategoryId: selectedSubcategoryId ? (selectedSubcategoryId as Id<"ticketSubcategories">) : null,
actorId: convexUserId as Id<"users">,
})
toast.success("Categoria atualizada!", { id: "ticket-category" })
} catch (categoryError) {
toast.error("Não foi possível atualizar a categoria.", { id: "ticket-category" })
setCategorySelection({
categoryId: currentCategoryId,
subcategoryId: currentSubcategoryId,
})
throw categoryError
}
} else if (categoryDirty && isManager) {
setCategorySelection({
categoryId: currentCategoryId,
subcategoryId: currentSubcategoryId,
})
}
if (queueDirty && !isManager) {
const queue = queues.find((item) => item.name === queueSelection)
if (!queue) {
toast.error("Fila selecionada não está disponível.")
setQueueSelection(currentQueueName)
throw new Error("Fila inválida")
}
toast.loading("Atualizando fila...", { id: "queue" })
try {
await changeQueue({
ticketId: ticket.id as Id<"tickets">,
queueId: queue.id as Id<"queues">,
actorId: convexUserId as Id<"users">,
})
toast.success("Fila atualizada!", { id: "queue" })
} catch (queueError) {
toast.error("Não foi possível atualizar a fila.", { id: "queue" })
setQueueSelection(currentQueueName)
throw queueError
}
} else if (queueDirty && isManager) {
setQueueSelection(currentQueueName)
}
if (dirty) {
toast.loading("Salvando alterações...", { id: "save-header" })
if (subject !== ticket.subject) {
await updateSubject({
ticketId: ticket.id as Id<"tickets">,
subject: subject.trim(),
actorId: convexUserId as Id<"users">,
})
}
if ((summary ?? "") !== (ticket.summary ?? "")) {
await updateSummary({
ticketId: ticket.id as Id<"tickets">,
summary: (summary ?? "").trim(),
actorId: convexUserId as Id<"users">,
})
}
toast.success("Cabeçalho atualizado!", { id: "save-header" })
}
setEditing(false)
} catch {
toast.error("Não foi possível salvar.", { id: "save-header" })
} finally {
setSaving(false)
}
}
function handleCancel() {
setSubject(ticket.subject)
setSummary(ticket.summary ?? "")
setCategorySelection({
categoryId: currentCategoryId,
subcategoryId: currentSubcategoryId,
})
setQueueSelection(currentQueueName)
setEditing(false)
}
useEffect(() => {
if (editing) return
setCategorySelection({
categoryId: ticket.category?.id ?? "",
subcategoryId: ticket.subcategory?.id ?? "",
})
setQueueSelection(ticket.queue ?? "")
}, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue])
useEffect(() => {
if (!editing) return
if (!selectedCategoryId) {
if (selectedSubcategoryId) {
setCategorySelection((prev) => ({ ...prev, subcategoryId: "" }))
}
return
}
const stillValid = secondaryOptions.some((option) => option.id === selectedSubcategoryId)
if (!stillValid && selectedSubcategoryId) {
setCategorySelection((prev) => ({ ...prev, subcategoryId: "" }))
}
}, [editing, secondaryOptions, selectedCategoryId, selectedSubcategoryId])
const workSummary = useMemo(() => {
if (workSummaryRemote !== undefined) return workSummaryRemote ?? null
if (!ticket.workSummary) return null
return {
ticketId: ticket.id as Id<"tickets">,
totalWorkedMs: ticket.workSummary.totalWorkedMs,
activeSession: ticket.workSummary.activeSession
? {
id: ticket.workSummary.activeSession.id as Id<"ticketWorkSessions">,
agentId: ticket.workSummary.activeSession.agentId as Id<"users">,
startedAt: ticket.workSummary.activeSession.startedAt.getTime(),
}
: null,
}
}, [ticket.id, ticket.workSummary, workSummaryRemote])
const isPlaying = Boolean(workSummary?.activeSession)
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!workSummary?.activeSession) return
const interval = setInterval(() => {
setNow(Date.now())
}, 1000)
return () => clearInterval(interval)
}, [workSummary?.activeSession])
const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
const formattedTotalWorked = useMemo(() => formatDuration(totalWorkedMs), [totalWorkedMs])
const updatedRelative = useMemo(
() => formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR }),
[ticket.updatedAt]
)
return (
<div className={cardClass}>
<div className="absolute right-6 top-6 flex items-center gap-3">
{workSummary ? (
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
<IconClock className="size-4 text-neutral-700" /> Tempo total: {formattedTotalWorked}
</Badge>
) : null}
{!editing ? (
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
Editar
</Button>
) : null}
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
</div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Badge className={referenceBadgeClass}>#{ticket.reference}</Badge>
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
<StatusSelect ticketId={ticket.id} value={status} />
<Button
size="sm"
className={isPlaying ? pauseButtonClass : startButtonClass}
onClick={async () => {
if (!convexUserId) return
toast.dismiss("work")
toast.loading(isPlaying ? "Pausando atendimento..." : "Iniciando atendimento...", { id: "work" })
try {
if (isPlaying) {
const result = await pauseWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
if (result?.status === "already_paused") {
toast.info("O atendimento já estava pausado", { id: "work" })
} else {
toast.success("Atendimento pausado", { id: "work" })
}
} else {
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
if (result?.status === "already_started") {
toast.info("O atendimento já estava em andamento", { id: "work" })
} else {
toast.success("Atendimento iniciado", { id: "work" })
}
}
} catch {
toast.error("Não foi possível atualizar o atendimento", { id: "work" })
}
}}
>
{isPlaying ? (
<>
<IconPlayerPause className="size-4 text-white" /> Pausar
</>
) : (
<>
<IconPlayerPlay className="size-4 text-white" /> Iniciar
</>
)}
</Button>
</div>
{editing ? (
<div className="space-y-2">
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="h-10 rounded-lg border border-slate-300 text-lg font-semibold text-neutral-900"
/>
<textarea
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={3}
className="w-full rounded-lg border border-slate-300 bg-white p-3 text-sm text-neutral-800 shadow-sm"
placeholder="Adicione um resumo opcional"
/>
</div>
) : (
<div className="space-y-1">
<h1 className="break-words text-2xl font-semibold text-neutral-900">{subject}</h1>
{summary ? <p className="max-w-2xl text-sm text-neutral-600">{summary}</p> : null}
</div>
)}
</div>
</div>
<Separator className="bg-slate-200" />
<div className="grid gap-4 text-sm text-neutral-600 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Categoria primária</span>
{editing ? (
<Select
disabled={saving || categoriesLoading || isManager}
value={selectedCategoryId ? selectedCategoryId : EMPTY_CATEGORY_VALUE}
onValueChange={(value) => {
if (isManager) return
if (value === EMPTY_CATEGORY_VALUE) {
setCategorySelection({ categoryId: "", subcategoryId: "" })
return
}
const category = categories.find((item) => item.id === value)
setCategorySelection({
categoryId: value,
subcategoryId: category?.secondary.find((option) => option.id === selectedSubcategoryId)?.id ?? "",
})
}}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder={categoriesLoading ? "Carregando..." : "Sem categoria"} />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value={EMPTY_CATEGORY_VALUE}>Sem categoria</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className={sectionValueClass}>{ticket.category?.name ?? "Sem categoria"}</span>
)}
</div>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Categoria secundária</span>
{editing ? (
<Select
disabled={
saving || categoriesLoading || !selectedCategoryId || isManager
}
value={selectedSubcategoryId ? selectedSubcategoryId : EMPTY_SUBCATEGORY_VALUE}
onValueChange={(value) => {
if (isManager) return
if (value === EMPTY_SUBCATEGORY_VALUE) {
setCategorySelection((prev) => ({ ...prev, subcategoryId: "" }))
return
}
setCategorySelection((prev) => ({ ...prev, subcategoryId: value }))
}}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue
placeholder={
!selectedCategoryId
? "Selecione uma primária"
: secondaryOptions.length === 0
? "Sem subcategoria"
: "Selecionar"
}
/>
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value={EMPTY_SUBCATEGORY_VALUE}>Sem subcategoria</SelectItem>
{secondaryOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className={sectionValueClass}>{ticket.subcategory?.name ?? "Sem subcategoria"}</span>
)}
</div>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Fila</span>
{editing ? (
<Select
disabled={isManager}
value={queueSelection}
onValueChange={(value) => {
if (isManager) return
setQueueSelection(value)
}}
>
<SelectTrigger className={smallSelectTriggerClass}>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{queues.map((queue) => (
<SelectItem key={queue.id} value={queue.name}>
{queue.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className={sectionValueClass}>{ticket.queue ?? "Sem fila"}</span>
)}
</div>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Solicitante</span>
<span className={sectionValueClass}>{ticket.requester.name}</span>
</div>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Responsável</span>
{editing ? (
<Select
disabled={isManager}
value={ticket.assignee?.id ?? ""}
onValueChange={async (value) => {
if (!convexUserId) return
if (isManager) return
toast.loading("Atribuindo responsável...", { id: "assignee" })
try {
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: convexUserId as Id<"users"> })
toast.success("Responsável atualizado!", { id: "assignee" })
} catch {
toast.error("Não foi possível atribuir.", { id: "assignee" })
}
}}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{agents.map((agent) => (
<SelectItem key={agent._id} value={agent._id}>
{agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className={sectionValueClass}>{ticket.assignee?.name ?? "Não atribuído"}</span>
)}
</div>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Criado em</span>
<span className={sectionValueClass}>{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Atualizado em</span>
<div className="flex items-center gap-2">
<span className={sectionValueClass}>{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
<span className={subtleBadgeClass}>{updatedRelative}</span>
</div>
</div>
{ticket.dueAt ? (
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>SLA até</span>
<span className={sectionValueClass}>{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div>
) : null}
{ticket.slaPolicy ? (
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Política</span>
<span className={sectionValueClass}>{ticket.slaPolicy.name}</span>
</div>
) : null}
{editing ? (
<div className="flex items-center justify-end gap-2 sm:col-span-2 lg:col-span-3">
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
Cancelar
</Button>
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!formDirty || saving}>
Salvar
</Button>
</div>
) : null}
</div>
</div>
)
}

View file

@ -0,0 +1,184 @@
import { format } from "date-fns"
import type { ComponentType } from "react"
import { ptBR } from "date-fns/locale"
import {
IconClockHour4,
IconFolders,
IconNote,
IconPaperclip,
IconSquareCheck,
IconUserCircle,
} from "@tabler/icons-react"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Card, CardContent } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Separator } from "@/components/ui/separator"
const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
CREATED: IconUserCircle,
STATUS_CHANGED: IconSquareCheck,
ASSIGNEE_CHANGED: IconUserCircle,
COMMENT_ADDED: IconNote,
COMMENT_EDITED: IconNote,
WORK_STARTED: IconClockHour4,
WORK_PAUSED: IconClockHour4,
SUBJECT_CHANGED: IconNote,
SUMMARY_CHANGED: IconNote,
QUEUE_CHANGED: IconSquareCheck,
PRIORITY_CHANGED: IconSquareCheck,
ATTACHMENT_REMOVED: IconPaperclip,
CATEGORY_CHANGED: IconFolders,
}
const timelineLabels: Record<string, string> = {
CREATED: "Criado",
STATUS_CHANGED: "Status alterado",
ASSIGNEE_CHANGED: "Responsável alterado",
COMMENT_ADDED: "Comentário adicionado",
COMMENT_EDITED: "Comentário editado",
WORK_STARTED: "Atendimento iniciado",
WORK_PAUSED: "Atendimento pausado",
SUBJECT_CHANGED: "Assunto atualizado",
SUMMARY_CHANGED: "Resumo atualizado",
QUEUE_CHANGED: "Fila alterada",
PRIORITY_CHANGED: "Prioridade alterada",
ATTACHMENT_REMOVED: "Anexo removido",
CATEGORY_CHANGED: "Categoria alterada",
}
interface TicketTimelineProps {
ticket: TicketWithDetails
}
export function TicketTimeline({ ticket }: TicketTimelineProps) {
const formatDuration = (durationMs: number) => {
if (!durationMs || durationMs <= 0) return "0s"
const totalSeconds = Math.floor(durationMs / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
}
if (minutes > 0) {
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
}
return `${seconds}s`
}
return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-5 px-4 pb-6">
{ticket.timeline.map((entry, index) => {
const Icon = timelineIcons[entry.type] ?? IconClockHour4
const isLast = index === ticket.timeline.length - 1
return (
<div key={entry.id} className="relative pl-11">
{!isLast && (
<span className="absolute left-[14px] top-6 h-full w-px bg-slate-200" aria-hidden />
)}
<span className="absolute left-0 top-0 flex size-8 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-700 shadow-sm">
<Icon className="size-4" />
</span>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-semibold text-neutral-900">
{timelineLabels[entry.type] ?? entry.type}
</span>
{entry.payload?.actorName ? (
<span className="flex items-center gap-1 text-xs text-neutral-500">
<Avatar className="size-5 border border-slate-200">
<AvatarImage src={entry.payload?.actorAvatar as string | undefined} alt={String(entry.payload?.actorName ?? "")} />
<AvatarFallback>
{String(entry.payload?.actorName ?? "").split(" ").slice(0, 2).map((part: string) => part[0]).join("").toUpperCase()}
</AvatarFallback>
</Avatar>
por {String(entry.payload?.actorName ?? "")}
</span>
) : null}
<span className="text-xs text-neutral-500">
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
</span>
</div>
{(() => {
const payload = (entry.payload || {}) as {
toLabel?: string
to?: string
assigneeName?: string
assigneeId?: string
queueName?: string
queueId?: string
requesterName?: string
authorName?: string
authorId?: string
actorName?: string
actorId?: string
from?: string
attachmentName?: string
sessionDurationMs?: number
categoryName?: string
subcategoryName?: string
}
let message: string | null = null
if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
message = "Status alterado para " + (payload.toLabel || payload.to)
}
if (entry.type === "ASSIGNEE_CHANGED" && (payload.assigneeName || payload.assigneeId)) {
message = "Responsável alterado" + (payload.assigneeName ? " para " + payload.assigneeName : "")
}
if (entry.type === "QUEUE_CHANGED" && (payload.queueName || payload.queueId)) {
message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "")
}
if (entry.type === "PRIORITY_CHANGED" && (payload.toLabel || payload.to)) {
message = "Prioridade alterada para " + (payload.toLabel || payload.to)
}
if (entry.type === "CREATED" && payload.requesterName) {
message = "Criado por " + payload.requesterName
}
if (entry.type === "COMMENT_ADDED" && (payload.authorName || payload.authorId)) {
message = "Comentário adicionado" + (payload.authorName ? " por " + payload.authorName : "")
}
if (entry.type === "COMMENT_EDITED" && (payload.actorName || payload.actorId || payload.authorName)) {
const name = payload.actorName ?? payload.authorName
message = "Comentário editado" + (name ? " por " + name : "")
}
if (entry.type === "SUBJECT_CHANGED" && (payload.to || payload.toLabel)) {
message = "Assunto alterado" + (payload.to ? " para \"" + payload.to + "\"" : "")
}
if (entry.type === "SUMMARY_CHANGED") {
message = "Resumo atualizado"
}
if (entry.type === "ATTACHMENT_REMOVED" && payload.attachmentName) {
message = `Anexo removido: ${payload.attachmentName}`
}
if (entry.type === "WORK_PAUSED" && typeof payload.sessionDurationMs === "number") {
message = `Tempo registrado: ${formatDuration(payload.sessionDurationMs)}`
}
if (entry.type === "CATEGORY_CHANGED") {
if (payload.categoryName || payload.subcategoryName) {
message = `Categoria alterada para ${payload.categoryName ?? ""}${
payload.subcategoryName ? `${payload.subcategoryName}` : ""
}`
} else {
message = "Categoria removida"
}
}
if (!message) return null
return (
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-600">
{message}
</div>
)
})()}
</div>
</div>
)
})}
<Separator className="bg-slate-200" />
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,234 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { IconFilter, IconRefresh } from "@tabler/icons-react"
import {
ticketChannelSchema,
ticketPrioritySchema,
ticketStatusSchema,
} from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const statusOptions = ticketStatusSchema.options.map((status) => ({
value: status,
label: {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}[status],
}))
const priorityOptions = ticketPrioritySchema.options.map((priority) => ({
value: priority,
label: {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}[priority],
}))
const channelOptions = ticketChannelSchema.options.map((channel) => ({
value: channel,
label: {
EMAIL: "E-mail",
WHATSAPP: "WhatsApp",
CHAT: "Chat",
PHONE: "Telefone",
API: "API",
MANUAL: "Manual",
}[channel],
}))
type QueueOption = string
export type TicketFiltersState = {
search: string
status: string | null
priority: string | null
queue: string | null
channel: string | null
}
export const defaultTicketFilters: TicketFiltersState = {
search: "",
status: null,
priority: null,
queue: null,
channel: null,
}
interface TicketsFiltersProps {
onChange?: (filters: TicketFiltersState) => void
queues?: QueueOption[]
}
const ALL_VALUE = "ALL"
export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
function setPartial(partial: Partial<TicketFiltersState>) {
setFilters((prev) => ({ ...prev, ...partial }))
}
// Propaga as mudancas de filtros para o componente pai sem disparar durante a renderizacao
useEffect(() => {
onChange?.(filters)
}, [filters, onChange])
const activeFilters = useMemo(() => {
const chips: string[] = []
if (filters.status) chips.push(`Status: ${filters.status}`)
if (filters.priority) chips.push(`Prioridade: ${filters.priority}`)
if (filters.queue) chips.push(`Fila: ${filters.queue}`)
if (filters.channel) chips.push(`Canal: ${filters.channel}`)
return chips
}, [filters])
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-col gap-2 md:flex-row">
<Input
placeholder="Buscar por assunto ou #ID"
value={filters.search}
onChange={(event) => setPartial({ search: event.target.value })}
className="md:max-w-sm"
/>
<Select
value={filters.queue ?? ALL_VALUE}
onValueChange={(value) => setPartial({ queue: value === ALL_VALUE ? null : value })}
>
<SelectTrigger className="md:w-[180px]">
<SelectValue placeholder="Fila" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>Todas as filas</SelectItem>
{queues.map((queue) => (
<SelectItem key={queue!} value={queue!}>
{queue}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-neutral-800 shadow-sm hover:bg-slate-50"
>
<IconFilter className="size-4 text-neutral-800" />
Filtros
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 space-y-4">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-neutral-500">
Status
</p>
<Select
value={filters.status ?? ALL_VALUE}
onValueChange={(value) => setPartial({ status: value === ALL_VALUE ? null : value })}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>Todos</SelectItem>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-neutral-500">
Prioridade
</p>
<Select
value={filters.priority ?? ALL_VALUE}
onValueChange={(value) => setPartial({ priority: value === ALL_VALUE ? null : value })}
>
<SelectTrigger>
<SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>Todas</SelectItem>
{priorityOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-neutral-500">
Canal
</p>
<Select
value={filters.channel ?? ALL_VALUE}
onValueChange={(value) => setPartial({ channel: value === ALL_VALUE ? null : value })}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>Todos</SelectItem>
{channelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</PopoverContent>
</Popover>
<Button
variant="ghost"
size="sm"
className="gap-2 rounded-lg px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100"
onClick={() => setPartial(defaultTicketFilters)}
>
<IconRefresh className="size-4" />
Resetar
</Button>
</div>
</div>
{activeFilters.length > 0 && (
<div className="flex flex-wrap gap-2">
{activeFilters.map((chip) => (
<Badge key={chip} className="rounded-full border border-slate-300 bg-slate-100 px-3 py-1 text-xs font-medium text-neutral-700">
{chip}
</Badge>
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,299 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { type LucideIcon, Code, FileText, Mail, MessageCircle, MessageSquare, Phone } from "lucide-react"
import { tickets as ticketsMock } from "@/lib/mocks/tickets"
import type { Ticket, TicketChannel, TicketStatus } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { NewTicketDialog } from "@/components/tickets/new-ticket-dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { PrioritySelect } from "@/components/tickets/priority-select"
import { cn } from "@/lib/utils"
const channelLabel: Record<TicketChannel, string> = {
EMAIL: "E-mail",
WHATSAPP: "WhatsApp",
CHAT: "Chat",
PHONE: "Telefone",
API: "API",
MANUAL: "Manual",
}
const channelIcon: Record<TicketChannel, LucideIcon> = {
EMAIL: Mail,
WHATSAPP: MessageCircle,
CHAT: MessageSquare,
PHONE: Phone,
API: Code,
MANUAL: FileText,
}
const cellClass = "px-6 py-5 align-top text-sm text-neutral-700 first:pl-8 last:pr-8"
const channelIconBadgeClass = "inline-flex size-8 items-center justify-center rounded-full border border-slate-200 bg-slate-50 text-neutral-700"
const categoryChipClass = "inline-flex items-center gap-1 rounded-full bg-slate-200/60 px-2.5 py-1 text-[11px] font-medium text-neutral-700"
const tableRowClass =
"group border-b border-slate-100 text-sm transition-colors hover:bg-slate-100/70 last:border-none"
const statusLabel: Record<TicketStatus, string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
const statusTone: Record<TicketStatus, string> = {
NEW: "text-slate-700",
OPEN: "text-sky-700",
PENDING: "text-amber-700",
ON_HOLD: "text-violet-700",
RESOLVED: "text-emerald-700",
CLOSED: "text-slate-600",
}
function formatDuration(ms?: number) {
if (!ms || ms <= 0) return "—"
const totalSeconds = Math.floor(ms / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
}
if (minutes > 0) {
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
}
return `${seconds}s`
}
function AssigneeCell({ ticket }: { ticket: Ticket }) {
if (!ticket.assignee) {
return <span className="text-sm text-neutral-600">Sem responsável</span>
}
const initials = ticket.assignee.name
.split(" ")
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("")
return (
<div className="flex items-center gap-2">
<Avatar className="size-8 border border-slate-200">
<AvatarImage src={ticket.assignee.avatarUrl} alt={ticket.assignee.name} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-semibold leading-none text-neutral-900">
{ticket.assignee.name}
</span>
<span className="text-xs text-neutral-600">
{ticket.assignee.teams?.[0] ?? "Agente"}
</span>
</div>
</div>
)
}
export type TicketsTableProps = {
tickets?: Ticket[]
}
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
const [now, setNow] = useState(() => Date.now())
const router = useRouter()
useEffect(() => {
const interval = setInterval(() => {
setNow(Date.now())
}, 1000)
return () => clearInterval(interval)
}, [])
const getWorkedMs = (ticket: Ticket) => {
const base = ticket.workSummary?.totalWorkedMs ?? 0
const activeStart = ticket.workSummary?.activeSession?.startedAt
if (activeStart instanceof Date) {
return base + Math.max(0, now - activeStart.getTime())
}
return base
}
return (
<Card className="gap-0 rounded-3xl border border-slate-200 bg-white py-0 shadow-sm">
<CardContent className="p-0">
<Table className="min-w-full overflow-hidden rounded-3xl">
<TableHeader className="bg-slate-100/80">
<TableRow className="bg-transparent text-[11px] uppercase tracking-wide text-neutral-600">
<TableHead className="w-[120px] px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
Ticket
</TableHead>
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
Assunto
</TableHead>
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 lg:table-cell">
Fila
</TableHead>
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 md:table-cell">
Canal
</TableHead>
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 md:table-cell">
Prioridade
</TableHead>
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
Status
</TableHead>
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 lg:table-cell">
Tempo
</TableHead>
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 xl:table-cell">
Responsável
</TableHead>
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
Atualizado
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tickets.map((ticket) => {
const ChannelIcon = channelIcon[ticket.channel] ?? MessageCircle
return (
<TableRow
key={ticket.id}
className={`${tableRowClass} cursor-pointer`}
role="link"
tabIndex={0}
onClick={() => router.push(`/tickets/${ticket.id}`)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
router.push(`/tickets/${ticket.id}`)
}
}}
>
<TableCell className={cellClass}>
<div className="flex flex-col gap-1">
<span className="font-semibold tracking-tight text-neutral-900">
#{ticket.reference}
</span>
<span className="text-xs text-neutral-500">
{ticket.queue ?? "Sem fila"}
</span>
</div>
</TableCell>
<TableCell className={cellClass}>
<div className="flex flex-col gap-1.5">
<span className="line-clamp-1 text-[15px] font-semibold text-neutral-900">
{ticket.subject}
</span>
<span className="line-clamp-1 text-sm text-neutral-600">
{ticket.summary ?? "Sem resumo"}
</span>
<div className="flex flex-col gap-1 text-xs text-neutral-500">
<span className="font-semibold text-neutral-700">{ticket.requester.name}</span>
{ticket.category ? (
<Badge className={categoryChipClass}>
{ticket.category.name}
{ticket.subcategory ? <span className="text-neutral-600"> {ticket.subcategory.name}</span> : null}
</Badge>
) : (
<span className="text-neutral-400">Sem categoria</span>
)}
</div>
</div>
</TableCell>
<TableCell className={`${cellClass} hidden lg:table-cell`}>
<span className="text-sm font-semibold text-neutral-800">
{ticket.queue ?? "Sem fila"}
</span>
</TableCell>
<TableCell className={`${cellClass} hidden md:table-cell`}>
<div className="flex items-center">
<span className="sr-only">Canal {channelLabel[ticket.channel]}</span>
<span
className={channelIconBadgeClass}
aria-hidden="true"
title={channelLabel[ticket.channel]}
>
<ChannelIcon className="size-4" />
</span>
</div>
</TableCell>
<TableCell className={`${cellClass} hidden md:table-cell`}>
<div
className="inline-flex"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
</div>
</TableCell>
<TableCell className={cellClass}>
<div className="flex flex-col gap-1">
<span className={cn("text-sm font-semibold", statusTone[ticket.status])}>
{statusLabel[ticket.status]}
</span>
{ticket.metrics?.timeWaitingMinutes ? (
<span className="text-xs text-neutral-500">
Em espera {ticket.metrics.timeWaitingMinutes} min
</span>
) : null}
</div>
</TableCell>
<TableCell className={`${cellClass} hidden lg:table-cell`}>
<div className="flex flex-col gap-1 text-sm text-neutral-600">
<span className="font-semibold text-neutral-800">{formatDuration(getWorkedMs(ticket))}</span>
{ticket.workSummary?.activeSession ? (
<span className="text-xs text-neutral-500">Em andamento</span>
) : null}
</div>
</TableCell>
<TableCell className={`${cellClass} hidden xl:table-cell`}>
<AssigneeCell ticket={ticket} />
</TableCell>
<TableCell className={cellClass}>
<span className="text-sm text-neutral-600">
{formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })}
</span>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
{tickets.length === 0 && (
<Empty className="my-6">
<EmptyHeader>
<EmptyMedia variant="icon">
<span className="inline-block size-3 rounded-full border border-slate-300 bg-[#00e8ff]" />
</EmptyMedia>
<EmptyTitle className="text-neutral-900">Nenhum ticket encontrado</EmptyTitle>
<EmptyDescription className="text-neutral-600">
Ajuste os filtros ou crie um novo ticket.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<NewTicketDialog />
</EmptyContent>
</Empty>
)}
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,68 @@
"use client"
import { useMemo, useState } from "react"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket, TicketQueueSummary } from "@/lib/schemas/ticket"
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
import { TicketsTable } from "@/components/tickets/tickets-table"
import { useAuth } from "@/lib/auth-client"
import { useDefaultQueues } from "@/hooks/use-default-queues"
export function TicketsView() {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
useDefaultQueues(tenantId)
const queues = useQuery(
convexUserId ? api.queues.summary : "skip",
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as TicketQueueSummary[] | undefined
const ticketsRaw = useQuery(
api.tickets.list,
convexUserId
? {
tenantId,
viewerId: convexUserId as Id<"users">,
status: filters.status ?? undefined,
priority: filters.priority ?? undefined,
channel: filters.channel ?? undefined,
queueId: undefined, // simplified: filter by queue name on client
search: filters.search || undefined,
}
: "skip"
)
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
const filteredTickets = useMemo(() => {
if (!filters.queue) return tickets
return tickets.filter((t: Ticket) => t.queue === filters.queue)
}, [tickets, filters.queue])
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q) => q.name)} />
{ticketsRaw === undefined ? (
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="grid gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-3">
<div className="h-4 w-48 animate-pulse rounded bg-slate-100" />
<div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
</div>
))}
</div>
</div>
) : (
<TicketsTable tickets={filteredTickets} />
)}
</div>
)
}

View file

@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View file

@ -0,0 +1,73 @@
"use client"
import dynamic from "next/dynamic"
import { cn } from "@/lib/utils"
const MeshGradient = dynamic(
() => import("@paper-design/shaders-react").then((mod) => mod.MeshGradient),
{ ssr: false }
)
const DotOrbit = dynamic(
() => import("@paper-design/shaders-react").then((mod) => mod.DotOrbit),
{ ssr: false }
)
function ShaderVisual() {
return (
<div className="absolute inset-0">
<MeshGradient
className="absolute inset-0"
colors={["#020202", "#04131f", "#062534", "#0b3947"]}
speed={0.8}
backgroundColor="#020202"
wireframe="true"
/>
<div className="absolute inset-0 opacity-70">
<DotOrbit
className="h-full w-full"
dotColor="#0f172a"
orbitColor="#155e75"
speed={1.4}
intensity={1.2}
/>
</div>
<div className="pointer-events-none absolute inset-0">
<div className="absolute left-1/4 top-1/3 h-24 w-24 rounded-full bg-cyan-300/10 blur-3xl animate-pulse" />
<div
className="absolute right-1/4 bottom-1/3 h-20 w-20 rounded-full bg-sky-500/15 blur-2xl animate-pulse"
style={{ animationDelay: "1s" }}
/>
<div
className="absolute right-1/3 top-1/2 h-16 w-16 rounded-full bg-white/10 blur-xl animate-pulse"
style={{ animationDelay: "0.5s" }}
/>
</div>
</div>
)
}
export function BackgroundPaperShaders({ className }: { className?: string }) {
return (
<div className={cn("shader-surface relative flex h-full w-full items-center justify-center bg-[#1f3d45]", className)}>
<div className="absolute h-[780px] w-[780px] -translate-y-6 rounded-full border border-white/10 opacity-30" />
<div className="absolute h-[640px] w-[640px] -translate-y-4 rounded-full border border-white/15 opacity-60" />
<div className="relative flex h-[520px] w-[520px] items-center justify-center rounded-full border border-white/20 bg-black/85 shadow-[0_0_160px_rgba(0,0,0,0.5)]">
<div className="absolute inset-6 rounded-full border border-white/15" />
<div className="relative h-[420px] w-[420px] overflow-hidden rounded-full">
<ShaderVisual />
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-10 text-center text-white">
<div className="text-sm uppercase tracking-[0.32em] text-white/50">Sistema de Chamados</div>
<h2 className="mt-4 text-xl font-semibold md:text-2xl">Atendimento moderno e colaborativo</h2>
<p className="mt-3 text-sm text-white/70">
Tenha visão unificada de todos os canais, monitore SLAs em tempo real e mantenha os clientes informados
com atualizações automáticas.
</p>
</div>
</div>
</div>
</div>
)
}
export default BackgroundPaperShaders

View file

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View file

@ -0,0 +1,58 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

357
src/components/ui/chart.tsx Normal file
View file

@ -0,0 +1,357 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View file

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View file

@ -0,0 +1,75 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[95vw] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border bg-popover p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col gap-1", className)} {...props} />
)
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription }
export { DialogFooter }

View file

@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View file

@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View file

@ -0,0 +1,132 @@
"use client";
import { useAction } from "convex/react";
// @ts-expect-error Convex generates runtime API without TS metadata
import { api } from "@/convex/_generated/api";
import { useCallback, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Spinner } from "@/components/ui/spinner";
import { Upload } from "lucide-react";
import { Progress } from "@/components/ui/progress";
type Uploaded = { storageId: string; name: string; size?: number; type?: string; previewUrl?: string };
export function Dropzone({
onUploaded,
maxFiles = 5,
maxSize = 10 * 1024 * 1024,
multiple = true,
className,
}: {
onUploaded?: (files: Uploaded[]) => void;
maxFiles?: number;
maxSize?: number;
multiple?: boolean;
className?: string;
}) {
const generateUrl = useAction(api.files.generateUploadUrl);
const inputRef = useRef<HTMLInputElement>(null);
const [drag, setDrag] = useState(false);
const [items, setItems] = useState<Array<{ id: string; name: string; progress: number; status: "idle" | "uploading" | "done" | "error" }>>([]);
const startUpload = useCallback(async (files: FileList | File[]) => {
const list = Array.from(files).slice(0, maxFiles);
const url = await generateUrl({});
const uploaded: Uploaded[] = [];
for (const file of list) {
if (file.size > maxSize) continue;
const id = `${file.name}-${file.size}-${Date.now()}`;
const localPreview = file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined;
setItems((prev) => [...prev, { id, name: file.name, progress: 0, status: "uploading" }]);
await new Promise<void>((resolve) => {
const form = new FormData();
form.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return;
const progress = Math.round((e.loaded / e.total) * 100);
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, progress } : it)));
};
xhr.onload = () => {
try {
const res = JSON.parse(xhr.responseText);
if (res?.storageId) {
uploaded.push({ storageId: res.storageId, name: file.name, size: file.size, type: file.type, previewUrl: localPreview });
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, progress: 100, status: "done" } : it)));
} else {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
}
} catch {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
}
resolve();
};
xhr.onerror = () => {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
resolve();
};
xhr.send(form);
});
}
if (uploaded.length) onUploaded?.(uploaded);
}, [generateUrl, maxFiles, maxSize, onUploaded]);
return (
<div className={cn("space-y-3", className)}>
<div
role="button"
tabIndex={0}
className={cn(
"group relative rounded-xl border border-dashed border-black/30 bg-white p-6 text-center transition-all focus:outline-none focus:ring-2 focus:ring-[#00d6eb]/40 focus:ring-offset-2",
drag ? "border-black bg-black/5" : "hover:border-black hover:bg-black/5"
)}
onClick={() => inputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
inputRef.current?.click()
}
}}
onDragEnter={(e) => { e.preventDefault(); setDrag(true); }}
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
onDragLeave={(e) => { e.preventDefault(); setDrag(false); }}
onDrop={(e) => { e.preventDefault(); setDrag(false); startUpload(e.dataTransfer.files); }}
>
<input
ref={inputRef}
type="file"
className="sr-only"
multiple={multiple}
onChange={(e) => e.target.files && startUpload(e.target.files)}
/>
<div className="flex flex-col items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#e5f9fc] text-[#0f172a] shadow-sm transition group-hover:bg-black group-hover:text-white">
{items.some((it) => it.status === "uploading") ? (
<Spinner />
) : (
<Upload className="size-5" />
)}
</div>
<p className="text-sm text-neutral-800">
Arraste arquivos aqui ou <span className="font-semibold text-black underline decoration-dotted">selecione</span>
</p>
<p className="text-xs text-neutral-500">Máximo {Math.round(maxSize/1024/1024)}MB Até {maxFiles} arquivos</p>
</div>
</div>
{items.length > 0 && (
<div className="space-y-2">
{items.map((it) => (
<div key={it.id} className="flex items-center justify-between gap-3 rounded-md border p-2 text-sm">
<span className="truncate">{it.name}</span>
<div className="flex min-w-[140px] items-center gap-2">
<Progress value={it.progress} className="h-1.5 w-24" />
<span className="w-10 text-right text-xs text-neutral-500">{it.progress}%</span>
</div>
</div>
))}
</div>
)}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show more