chore: reorganize project structure and ensure default queues
This commit is contained in:
parent
854887f499
commit
1cccb852a5
201 changed files with 417 additions and 838 deletions
16
src/app/ConvexClientProvider.tsx
Normal file
16
src/app/ConvexClientProvider.tsx
Normal 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>;
|
||||
}
|
||||
|
||||
22
src/app/admin/channels/page.tsx
Normal file
22
src/app/admin/channels/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
src/app/admin/fields/page.tsx
Normal file
24
src/app/admin/fields/page.tsx
Normal 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
11
src/app/admin/layout.tsx
Normal 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
79
src/app/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
src/app/admin/slas/page.tsx
Normal file
22
src/app/admin/slas/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
src/app/admin/teams/page.tsx
Normal file
22
src/app/admin/teams/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
src/app/api/admin/invites/[id]/route.ts
Normal file
93
src/app/api/admin/invites/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
205
src/app/api/admin/invites/route.ts
Normal file
205
src/app/api/admin/invites/route.ts
Normal 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 })
|
||||
}
|
||||
122
src/app/api/admin/users/route.ts
Normal file
122
src/app/api/admin/users/route.ts
Normal 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 })
|
||||
}
|
||||
5
src/app/api/auth/[...all]/route.ts
Normal file
5
src/app/api/auth/[...all]/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { toNextJsHandler } from "better-auth/next-js"
|
||||
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth.handler)
|
||||
196
src/app/api/invites/[token]/route.ts
Normal file
196
src/app/api/invites/[token]/route.ts
Normal 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
614
src/app/dashboard/data.json
Normal 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"
|
||||
}
|
||||
]
|
||||
27
src/app/dashboard/page.tsx
Normal file
27
src/app/dashboard/page.tsx
Normal 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
28
src/app/dev/seed/page.tsx
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
182
src/app/globals.css
Normal file
182
src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
40
src/app/invite/[token]/page.tsx
Normal file
40
src/app/invite/[token]/page.tsx
Normal 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
47
src/app/layout.tsx
Normal 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
74
src/app/login/page.tsx
Normal 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
5
src/app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function Home() {
|
||||
redirect("/dashboard")
|
||||
}
|
||||
24
src/app/play/page.tsx
Normal file
24
src/app/play/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
src/app/portal/layout.tsx
Normal file
7
src/app/portal/layout.tsx
Normal 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
11
src/app/portal/page.tsx
Normal 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")
|
||||
}
|
||||
5
src/app/portal/tickets/[id]/page.tsx
Normal file
5
src/app/portal/tickets/[id]/page.tsx
Normal 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} />
|
||||
}
|
||||
12
src/app/portal/tickets/new/page.tsx
Normal file
12
src/app/portal/tickets/new/page.tsx
Normal 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 />
|
||||
}
|
||||
12
src/app/portal/tickets/page.tsx
Normal file
12
src/app/portal/tickets/page.tsx
Normal 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 />
|
||||
}
|
||||
22
src/app/reports/backlog/page.tsx
Normal file
22
src/app/reports/backlog/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
src/app/reports/csat/page.tsx
Normal file
22
src/app/reports/csat/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
src/app/reports/sla/page.tsx
Normal file
22
src/app/reports/sla/page.tsx
Normal 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
13
src/app/settings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
src/app/settings/templates/page.tsx
Normal file
24
src/app/settings/templates/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
src/app/tickets/[id]/page.tsx
Normal file
32
src/app/tickets/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
333
src/app/tickets/new/page.tsx
Normal file
333
src/app/tickets/new/page.tsx
Normal 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
6
src/app/tickets/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { TicketsPageClient } from "./tickets-page-client"
|
||||
|
||||
export default function TicketsPage() {
|
||||
return <TicketsPageClient />
|
||||
}
|
||||
|
||||
52
src/app/tickets/tickets-page-client.tsx
Normal file
52
src/app/tickets/tickets-page-client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
430
src/components/admin/admin-users-manager.tsx
Normal file
430
src/components/admin/admin-users-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
555
src/components/admin/categories/categories-manager.tsx
Normal file
555
src/components/admin/categories/categories-manager.tsx
Normal 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 há 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>
|
||||
)
|
||||
}
|
||||
551
src/components/admin/fields/fields-manager.tsx
Normal file
551
src/components/admin/fields/fields-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
332
src/components/admin/queues/queues-manager.tsx
Normal file
332
src/components/admin/queues/queues-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
390
src/components/admin/slas/slas-manager.tsx
Normal file
390
src/components/admin/slas/slas-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
428
src/components/admin/teams/teams-manager.tsx
Normal file
428
src/components/admin/teams/teams-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
src/components/app-shell.tsx
Normal file
25
src/components/app-shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
222
src/components/app-sidebar.tsx
Normal file
222
src/components/app-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
29
src/components/auth/auth-guard.tsx
Normal file
29
src/components/auth/auth-guard.tsx
Normal 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
|
||||
}
|
||||
19
src/components/background-paper-shaders-wrapper.tsx
Normal file
19
src/components/background-paper-shaders-wrapper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
src/components/background-paper-shaders.tsx
Normal file
116
src/components/background-paper-shaders.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
224
src/components/chart-area-interactive.tsx
Normal file
224
src/components/chart-area-interactive.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
807
src/components/data-table.tsx
Normal file
807
src/components/data-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
174
src/components/invite/invite-accept-form.tsx
Normal file
174
src/components/invite/invite-accept-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
src/components/login-form.tsx
Normal file
124
src/components/login-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
src/components/nav-documents.tsx
Normal file
92
src/components/nav-documents.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
src/components/nav-main.tsx
Normal file
58
src/components/nav-main.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
src/components/nav-secondary.tsx
Normal file
42
src/components/nav-secondary.tsx
Normal 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
154
src/components/nav-user.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
src/components/page-header.tsx
Normal file
42
src/components/page-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
125
src/components/portal/portal-shell.tsx
Normal file
125
src/components/portal/portal-shell.tsx
Normal 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>© {new Date().getFullYear()} Sistema de chamados</span>
|
||||
<span>Suporte: suporte@sistema.dev</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
src/components/portal/portal-ticket-card.tsx
Normal file
104
src/components/portal/portal-ticket-card.tsx
Normal 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>·</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>
|
||||
)
|
||||
}
|
||||
303
src/components/portal/portal-ticket-detail.tsx
Normal file
303
src/components/portal/portal-ticket-detail.tsx
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
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>
|
||||
)
|
||||
}
|
||||
199
src/components/portal/portal-ticket-form.tsx
Normal file
199
src/components/portal/portal-ticket-form.tsx
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
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>
|
||||
)
|
||||
}
|
||||
89
src/components/portal/portal-ticket-list.tsx
Normal file
89
src/components/portal/portal-ticket-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
172
src/components/reports/backlog-report.tsx
Normal file
172
src/components/reports/backlog-report.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
src/components/reports/csat-report.tsx
Normal file
111
src/components/reports/csat-report.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
src/components/reports/sla-report.tsx
Normal file
124
src/components/reports/sla-report.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
src/components/search-form.tsx
Normal file
28
src/components/search-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
src/components/section-cards.tsx
Normal file
171
src/components/section-cards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
322
src/components/settings/comment-templates-manager.tsx
Normal file
322
src/components/settings/comment-templates-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
293
src/components/settings/settings-content.tsx
Normal file
293
src/components/settings/settings-content.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
src/components/site-header.tsx
Normal file
56
src/components/site-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
src/components/tickets/category-select.tsx
Normal file
139
src/components/tickets/category-select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
src/components/tickets/delete-ticket-dialog.tsx
Normal file
64
src/components/tickets/delete-ticket-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
408
src/components/tickets/new-ticket-dialog.tsx
Normal file
408
src/components/tickets/new-ticket-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
166
src/components/tickets/play-next-ticket-card.tsx
Normal file
166
src/components/tickets/play-next-ticket-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
src/components/tickets/priority-pill.tsx
Normal file
16
src/components/tickets/priority-pill.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
84
src/components/tickets/priority-select.tsx
Normal file
84
src/components/tickets/priority-select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
165
src/components/tickets/recent-tickets-panel.tsx
Normal file
165
src/components/tickets/recent-tickets-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/components/tickets/status-badge.tsx
Normal file
26
src/components/tickets/status-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
src/components/tickets/status-select.tsx
Normal file
71
src/components/tickets/status-select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
626
src/components/tickets/ticket-comments.rich.tsx
Normal file
626
src/components/tickets/ticket-comments.rich.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
src/components/tickets/ticket-detail-static.tsx
Normal file
21
src/components/tickets/ticket-detail-static.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
79
src/components/tickets/ticket-detail-view.tsx
Normal file
79
src/components/tickets/ticket-detail-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/components/tickets/ticket-details-panel.tsx
Normal file
93
src/components/tickets/ticket-details-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
src/components/tickets/ticket-queue-summary.tsx
Normal file
74
src/components/tickets/ticket-queue-summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
544
src/components/tickets/ticket-summary-header.tsx
Normal file
544
src/components/tickets/ticket-summary-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
184
src/components/tickets/ticket-timeline.tsx
Normal file
184
src/components/tickets/ticket-timeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
234
src/components/tickets/tickets-filters.tsx
Normal file
234
src/components/tickets/tickets-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
299
src/components/tickets/tickets-table.tsx
Normal file
299
src/components/tickets/tickets-table.tsx
Normal 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 há {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>
|
||||
)
|
||||
}
|
||||
68
src/components/tickets/tickets-view.tsx
Normal file
68
src/components/tickets/tickets-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
src/components/ui/avatar.tsx
Normal file
53
src/components/ui/avatar.tsx
Normal 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 }
|
||||
73
src/components/ui/background-paper-shaders.tsx
Normal file
73
src/components/ui/background-paper-shaders.tsx
Normal 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
|
||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal 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 }
|
||||
109
src/components/ui/breadcrumb.tsx
Normal file
109
src/components/ui/breadcrumb.tsx
Normal 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,
|
||||
}
|
||||
58
src/components/ui/button.tsx
Normal file
58
src/components/ui/button.tsx
Normal 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 }
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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
357
src/components/ui/chart.tsx
Normal 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,
|
||||
}
|
||||
32
src/components/ui/checkbox.tsx
Normal file
32
src/components/ui/checkbox.tsx
Normal 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 }
|
||||
75
src/components/ui/dialog.tsx
Normal file
75
src/components/ui/dialog.tsx
Normal 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 }
|
||||
|
||||
135
src/components/ui/drawer.tsx
Normal file
135
src/components/ui/drawer.tsx
Normal 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,
|
||||
}
|
||||
257
src/components/ui/dropdown-menu.tsx
Normal file
257
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
132
src/components/ui/dropzone.tsx
Normal file
132
src/components/ui/dropzone.tsx
Normal 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
Loading…
Add table
Add a link
Reference in a new issue