diff --git a/apps/desktop/src/components/DeactivationScreen.tsx b/apps/desktop/src/components/DeactivationScreen.tsx new file mode 100644 index 0000000..fc97b58 --- /dev/null +++ b/apps/desktop/src/components/DeactivationScreen.tsx @@ -0,0 +1,43 @@ +import React from "react" +import { RefreshCw, Mail } from "lucide-react" + +export function DeactivationScreen({ companyName }: { companyName?: string | null }) { + return ( +
+
+
+
+ +
+

Máquina desativada

+

+ Esta máquina foi desativada temporariamente por um administrador da Rever. Enquanto estiver nessa situação, + o acesso ao portal e o envio de informações ficam bloqueados. +

+ {companyName ? ( + + {companyName} + + ) : null} +
+
+
+

Como proceder?

+
    +
  • + 1. Caso precise restaurar o acesso, entre em contato com a equipe de suporte da Rever. +
  • +
  • + 2. Informe o identificador desta máquina e peça a reativação. +
  • +
+
+
+ + suporte@rever.com.br +
+
+
+
+ ) +} diff --git a/convex/machines.ts b/convex/machines.ts index 23a0cd5..f33f4f9 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -440,6 +440,7 @@ export const register = mutation({ lastHeartbeatAt: now, updatedAt: now, status: "online", + isActive: true, registeredBy: args.registeredBy ?? existing.registeredBy, persona: existing.persona, assignedUserId: existing.assignedUserId, @@ -463,6 +464,7 @@ export const register = mutation({ metadata: metadataPatch ? mergeMetadata(undefined, metadataPatch) : undefined, lastHeartbeatAt: now, status: "online", + isActive: true, createdAt: now, updatedAt: now, registeredBy: args.registeredBy, @@ -716,6 +718,7 @@ export const resolveToken = mutation({ status: machine.status, lastHeartbeatAt: machine.lastHeartbeatAt, metadata: machine.metadata, + isActive: machine.isActive ?? true, }, token: { expiresAt: token.expiresAt, @@ -753,7 +756,9 @@ export const listByTenant = query({ const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs) const manualStatus = (machine.status ?? "").toLowerCase() let derivedStatus: string - if (["maintenance", "blocked"].includes(manualStatus)) { + if (machine.isActive === false) { + derivedStatus = "deactivated" + } else if (["maintenance", "blocked"].includes(manualStatus)) { derivedStatus = manualStatus } else if (machine.lastHeartbeatAt) { const age = now - machine.lastHeartbeatAt @@ -810,6 +815,7 @@ export const listByTenant = query({ assignedUserName: machine.assignedUserName ?? null, assignedUserRole: machine.assignedUserRole ?? null, status: derivedStatus, + isActive: machine.isActive ?? true, lastHeartbeatAt: machine.lastHeartbeatAt ?? null, heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null, registeredBy: machine.registeredBy ?? null, @@ -988,6 +994,7 @@ export const getContext = query({ assignedUserRole: machine.assignedUserRole ?? null, metadata: machine.metadata ?? null, authEmail: machine.authEmail ?? null, + isActive: machine.isActive ?? true, } }, }) @@ -1069,6 +1076,37 @@ export const rename = mutation({ }, }) +export const toggleActive = mutation({ + args: { + machineId: v.id("machines"), + actorId: v.id("users"), + active: v.boolean(), + }, + handler: async (ctx, { machineId, actorId, active }) => { + const machine = await ctx.db.get(machineId) + if (!machine) { + throw new ConvexError("Máquina não encontrada") + } + + const actor = await ctx.db.get(actorId) + if (!actor || actor.tenantId !== machine.tenantId) { + throw new ConvexError("Acesso negado ao tenant da máquina") + } + const normalizedRole = (actor.role ?? "AGENT").toUpperCase() + const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"]) + if (!STAFF.has(normalizedRole)) { + throw new ConvexError("Apenas equipe interna pode atualizar o status da máquina") + } + + await ctx.db.patch(machineId, { + isActive: active, + updatedAt: Date.now(), + }) + + return { ok: true } + }, +}) + export const remove = mutation({ args: { machineId: v.id("machines"), diff --git a/convex/reports.ts b/convex/reports.ts index 0d5ae75..59a64e7 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -375,8 +375,20 @@ export const dashboardOverview = query({ const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); - const surveys = await collectCsatSurveys(ctx, tickets); - const averageScore = average(surveys.map((item) => item.score)); + const resolvedLastWindow = tickets.filter( + (ticket) => ticket.resolvedAt && ticket.resolvedAt >= lastWindowStart && ticket.resolvedAt < now + ); + const resolvedPreviousWindow = tickets.filter( + (ticket) => + ticket.resolvedAt && + ticket.resolvedAt >= previousWindowStart && + ticket.resolvedAt < lastWindowStart + ); + const resolutionRate = tickets.length > 0 ? (resolvedLastWindow.length / tickets.length) * 100 : null; + const resolutionDelta = + resolvedPreviousWindow.length > 0 + ? ((resolvedLastWindow.length - resolvedPreviousWindow.length) / resolvedPreviousWindow.length) * 100 + : null; return { newTickets: { @@ -394,9 +406,11 @@ export const dashboardOverview = query({ total: awaitingTickets.length, atRisk: atRiskTickets.length, }, - csat: { - averageScore, - totalSurveys: surveys.length, + resolution: { + resolvedLast7d: resolvedLastWindow.length, + previousResolved: resolvedPreviousWindow.length, + rate: resolutionRate, + deltaPercentage: resolutionDelta, }, }; }, diff --git a/convex/schema.ts b/convex/schema.ts index bece478..c3293e5 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -264,6 +264,7 @@ export default defineSchema({ metadata: v.optional(v.any()), lastHeartbeatAt: v.optional(v.number()), status: v.optional(v.string()), + isActive: v.optional(v.boolean()), createdAt: v.number(), updatedAt: v.number(), registeredBy: v.optional(v.string()), diff --git a/src/app/admin/clients/page.tsx b/src/app/admin/clients/page.tsx new file mode 100644 index 0000000..4a44752 --- /dev/null +++ b/src/app/admin/clients/page.tsx @@ -0,0 +1,87 @@ +import { AppShell } from "@/components/app-shell" +import { SiteHeader } from "@/components/site-header" +import { requireStaffSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { prisma } from "@/lib/prisma" +import { AdminClientsManager, type AdminClient } from "@/components/admin/clients/admin-clients-manager" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export default async function AdminClientsPage() { + const session = await requireStaffSession() + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + const users = await prisma.user.findMany({ + where: { + tenantId, + role: { in: ["MANAGER", "COLLABORATOR"] }, + }, + include: { + company: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }) + + const emails = users.map((user) => user.email) + const authUsers = await prisma.authUser.findMany({ + where: { email: { in: emails } }, + select: { id: true, email: true, updatedAt: true, createdAt: true }, + }) + + const sessions = await prisma.authSession.findMany({ + where: { userId: { in: authUsers.map((auth) => auth.id) } }, + orderBy: { updatedAt: "desc" }, + select: { userId: true, updatedAt: true }, + }) + + const sessionByUserId = new Map() + for (const sessionRow of sessions) { + if (!sessionByUserId.has(sessionRow.userId)) { + sessionByUserId.set(sessionRow.userId, sessionRow.updatedAt) + } + } + + const authByEmail = new Map() + for (const authUser of authUsers) { + authByEmail.set(authUser.email.toLowerCase(), { + id: authUser.id, + updatedAt: authUser.updatedAt, + createdAt: authUser.createdAt, + }) + } + + const initialClients: AdminClient[] = users.map((user) => { + const auth = authByEmail.get(user.email.toLowerCase()) + const lastSeenAt = auth ? sessionByUserId.get(auth.id) ?? auth.updatedAt : null + const normalizedRole = user.role === "MANAGER" ? "MANAGER" : "COLLABORATOR" + return { + id: user.id, + email: user.email, + name: user.name ?? user.email, + role: normalizedRole, + companyId: user.companyId ?? null, + companyName: user.company?.name ?? null, + tenantId: user.tenantId, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + authUserId: auth?.id ?? null, + lastSeenAt: lastSeenAt ? lastSeenAt.toISOString() : null, + } + }) + + return ( + } + > +
+ +
+
+ ) +} diff --git a/src/app/admin/companies/page.tsx b/src/app/admin/companies/page.tsx index b7442d2..d8df1f1 100644 --- a/src/app/admin/companies/page.tsx +++ b/src/app/admin/companies/page.tsx @@ -28,10 +28,7 @@ export default async function AdminCompaniesPage() { return ( + } >
diff --git a/src/app/api/admin/clients/route.ts b/src/app/api/admin/clients/route.ts new file mode 100644 index 0000000..5156827 --- /dev/null +++ b/src/app/api/admin/clients/route.ts @@ -0,0 +1,156 @@ +import { NextResponse } from "next/server" + +import { prisma } from "@/lib/prisma" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { assertStaffSession } from "@/lib/auth-server" +import { isAdmin } from "@/lib/authz" + +export const runtime = "nodejs" + +const ALLOWED_ROLES = ["MANAGER", "COLLABORATOR"] as const + +type AllowedRole = (typeof ALLOWED_ROLES)[number] + +function normalizeRole(role?: string | null): AllowedRole { + const normalized = (role ?? "COLLABORATOR").toUpperCase() + return ALLOWED_ROLES.includes(normalized as AllowedRole) ? (normalized as AllowedRole) : "COLLABORATOR" +} + +export async function GET() { + const session = await assertStaffSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + const users = await prisma.user.findMany({ + where: { + tenantId, + role: { in: [...ALLOWED_ROLES] }, + }, + include: { + company: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }) + + const emails = users.map((user) => user.email) + const authUsers = await prisma.authUser.findMany({ + where: { email: { in: emails } }, + select: { + id: true, + email: true, + updatedAt: true, + createdAt: true, + }, + }) + + const sessions = await prisma.authSession.findMany({ + where: { userId: { in: authUsers.map((authUser) => authUser.id) } }, + orderBy: { updatedAt: "desc" }, + select: { + userId: true, + updatedAt: true, + }, + }) + + const sessionByUserId = new Map() + for (const sessionRow of sessions) { + if (!sessionByUserId.has(sessionRow.userId)) { + sessionByUserId.set(sessionRow.userId, sessionRow.updatedAt) + } + } + + const authByEmail = new Map() + for (const authUser of authUsers) { + authByEmail.set(authUser.email.toLowerCase(), { + id: authUser.id, + updatedAt: authUser.updatedAt, + createdAt: authUser.createdAt, + }) + } + + const items = users.map((user) => { + const auth = authByEmail.get(user.email.toLowerCase()) + const lastSeenAt = auth ? sessionByUserId.get(auth.id) ?? auth.updatedAt : null + return { + id: user.id, + email: user.email, + name: user.name, + role: normalizeRole(user.role), + companyId: user.companyId, + companyName: user.company?.name ?? null, + tenantId: user.tenantId, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + authUserId: auth?.id ?? null, + lastSeenAt: lastSeenAt ? lastSeenAt.toISOString() : null, + } + }) + + return NextResponse.json({ items }) +} + +export async function DELETE(request: Request) { + const session = await assertStaffSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + if (!isAdmin(session.user.role)) { + return NextResponse.json({ error: "Apenas administradores podem excluir clientes." }, { status: 403 }) + } + + const json = await request.json().catch(() => null) + const ids = Array.isArray(json?.ids) ? (json.ids as string[]) : [] + if (ids.length === 0) { + return NextResponse.json({ error: "Nenhum cliente selecionado." }, { status: 400 }) + } + + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + const users = await prisma.user.findMany({ + where: { + id: { in: ids }, + tenantId, + role: { in: [...ALLOWED_ROLES] }, + }, + select: { + id: true, + email: true, + }, + }) + + if (users.length === 0) { + return NextResponse.json({ deletedIds: [] }) + } + + const emails = users.map((user) => user.email.toLowerCase()) + const authUsers = await prisma.authUser.findMany({ + where: { + email: { in: emails }, + }, + select: { + id: true, + }, + }) + + const authUserIds = authUsers.map((authUser) => authUser.id) + + await prisma.$transaction(async (tx) => { + if (authUserIds.length > 0) { + await tx.authSession.deleteMany({ where: { userId: { in: authUserIds } } }) + await tx.authAccount.deleteMany({ where: { userId: { in: authUserIds } } }) + await tx.authUser.deleteMany({ where: { id: { in: authUserIds } } }) + } + await tx.user.deleteMany({ where: { id: { in: users.map((user) => user.id) } } }) + }) + + return NextResponse.json({ deletedIds: users.map((user) => user.id) }) +} diff --git a/src/app/api/admin/machines/toggle-active/route.ts b/src/app/api/admin/machines/toggle-active/route.ts new file mode 100644 index 0000000..2347942 --- /dev/null +++ b/src/app/api/admin/machines/toggle-active/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { ConvexHttpClient } from "convex/browser" + +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { api } from "@/convex/_generated/api" + +export const runtime = "nodejs" + +const schema = z.object({ + machineId: z.string().min(1), + active: z.boolean(), +}) + +export async function POST(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const payload = await request.json().catch(() => null) + const parsed = schema.safeParse(payload) + if (!parsed.success) { + return NextResponse.json({ error: "Payload inválido", details: parsed.error.flatten() }, { status: 400 }) + } + + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + try { + const convex = new ConvexHttpClient(convexUrl) + const ensured = await convex.mutation(api.users.ensureUser, { + tenantId, + email: session.user.email, + name: session.user.name ?? session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + const actorId = ensured?._id + if (!actorId) { + return NextResponse.json({ error: "Falha ao obter ID do usuário no Convex" }, { status: 500 }) + } + + const client = convex as unknown as { mutation: (name: string, args: unknown) => Promise } + await client.mutation("machines:toggleActive", { + machineId: parsed.data.machineId, + actorId, + active: parsed.data.active, + }) + + return NextResponse.json({ ok: true }) + } catch (error) { + console.error("[machines.toggleActive] Falha ao atualizar status", error) + return NextResponse.json({ error: "Falha ao atualizar status da máquina" }, { status: 500 }) + } +} diff --git a/src/app/api/auth/get-session/route.ts b/src/app/api/auth/get-session/route.ts new file mode 100644 index 0000000..3936d52 --- /dev/null +++ b/src/app/api/auth/get-session/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server" + +import { auth } from "@/lib/auth" + +export const runtime = "nodejs" + +export async function GET(request: Request) { + const result = await auth.api.getSession({ headers: request.headers, request, asResponse: true }) + + if (!result) { + return NextResponse.json({ user: null }, { status: 200 }) + } + + const body = await result.json() + const response = NextResponse.json(body, { + status: result.status, + }) + + const headersWithGetSetCookie = result.headers as Headers & { getSetCookie?: () => string[] | undefined } + let setCookieHeaders = + typeof headersWithGetSetCookie.getSetCookie === "function" + ? headersWithGetSetCookie.getSetCookie() ?? [] + : [] + + if (setCookieHeaders.length === 0) { + const single = result.headers.get("set-cookie") + if (single) { + setCookieHeaders = [single] + } + } + + for (const cookie of setCookieHeaders) { + response.headers.append("set-cookie", cookie) + } + + return response +} diff --git a/src/app/api/machines/register/route.ts b/src/app/api/machines/register/route.ts index 873bc44..2541691 100644 --- a/src/app/api/machines/register/route.ts +++ b/src/app/api/machines/register/route.ts @@ -160,6 +160,7 @@ export async function POST(request: Request) { name: collaborator.name ?? collaborator.email, tenantId, companyId: companyRecord.id, + role: persona === "manager" ? "MANAGER" : "COLLABORATOR", }) if (persona) { diff --git a/src/app/api/portal/profile/route.ts b/src/app/api/portal/profile/route.ts index f1ab7cc..3e554a9 100644 --- a/src/app/api/portal/profile/route.ts +++ b/src/app/api/portal/profile/route.ts @@ -22,10 +22,25 @@ const updateSchema = z.object({ export async function PATCH(request: Request) { const session = await requireAuthenticatedSession() - const role = (session.user.role ?? "").toLowerCase() - if (role !== "collaborator" && role !== "manager") { + const normalizedRole = (session.user.role ?? "").toLowerCase() + const persona = (session.user.machinePersona ?? "").toLowerCase() + const allowedRoles = new Set(["collaborator", "manager", "admin", "agent"]) + const isMachinePersonaAllowed = normalizedRole === "machine" && (persona === "collaborator" || persona === "manager") + if (!allowedRoles.has(normalizedRole) && !isMachinePersonaAllowed) { return NextResponse.json({ error: "Acesso não autorizado" }, { status: 403 }) } + const effectiveRole = + normalizedRole === "admin" + ? "ADMIN" + : normalizedRole === "agent" + ? "AGENT" + : normalizedRole === "manager" + ? "MANAGER" + : normalizedRole === "collaborator" + ? "COLLABORATOR" + : persona === "manager" + ? "MANAGER" + : "COLLABORATOR" let payload: unknown try { @@ -132,14 +147,14 @@ export async function PATCH(request: Request) { update: { name, tenantId, - role: role === "manager" ? "MANAGER" : "COLLABORATOR", + role: effectiveRole, companyId: companyId ?? undefined, }, create: { email: effectiveEmail, name, tenantId, - role: role === "manager" ? "MANAGER" : "COLLABORATOR", + role: effectiveRole, companyId: companyId ?? undefined, }, }) @@ -149,6 +164,7 @@ export async function PATCH(request: Request) { name, tenantId, companyId, + role: effectiveRole, }) if (env.NEXT_PUBLIC_CONVEX_URL) { @@ -158,7 +174,7 @@ export async function PATCH(request: Request) { tenantId, email: effectiveEmail, name, - role: role === "manager" ? "MANAGER" : "COLLABORATOR", + role: effectiveRole, }) } catch (error) { console.warn("[portal.profile] Falha ao sincronizar usuário no Convex", error) diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 4df910e..679b1e8 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -16,8 +16,12 @@ export default async function Dashboard() { Modo play} - primaryAction={} + primaryAlignment="center" + primaryAction={ +
+ +
+ } /> } > diff --git a/src/app/portal/profile/page.tsx b/src/app/portal/profile/page.tsx index ecc10f2..e6a00a9 100644 --- a/src/app/portal/profile/page.tsx +++ b/src/app/portal/profile/page.tsx @@ -13,7 +13,9 @@ export default async function PortalProfilePage() { const session = await requireAuthenticatedSession() const role = (session.user.role ?? "").toLowerCase() const persona = (session.user.machinePersona ?? "").toLowerCase() - const allowed = role === "collaborator" || role === "manager" || persona === "collaborator" || persona === "manager" + const allowedRoles = new Set(["collaborator", "manager", "admin", "agent"]) + const isMachinePersonaAllowed = role === "machine" && (persona === "collaborator" || persona === "manager") + const allowed = allowedRoles.has(role) || isMachinePersonaAllowed if (!allowed) { redirect("/portal") } diff --git a/src/app/reports/hours/page.tsx b/src/app/reports/hours/page.tsx index 9923ada..6038913 100644 --- a/src/app/reports/hours/page.tsx +++ b/src/app/reports/hours/page.tsx @@ -11,8 +11,8 @@ export default async function ReportsHoursPage() { } > diff --git a/src/components/admin/clients/admin-clients-manager.tsx b/src/components/admin/clients/admin-clients-manager.tsx new file mode 100644 index 0000000..c466f99 --- /dev/null +++ b/src/components/admin/clients/admin-clients-manager.tsx @@ -0,0 +1,391 @@ +"use client" + +import { useCallback, useMemo, useState, useTransition } from "react" +import { format } from "date-fns" +import { ptBR } from "date-fns/locale" +import { toast } from "sonner" +import { + ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + SortingState, +} from "@tanstack/react-table" +import { + IconChevronLeft, + IconChevronRight, + IconFilter, + IconTrash, + IconUser, +} from "@tabler/icons-react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" + +export type AdminClient = { + id: string + email: string + name: string + role: "MANAGER" | "COLLABORATOR" + companyId: string | null + companyName: string | null + tenantId: string + createdAt: string + updatedAt: string + authUserId: string | null + lastSeenAt: string | null +} + +const ROLE_LABEL: Record = { + MANAGER: "Gestor", + COLLABORATOR: "Colaborador", +} + +function formatDate(dateString: string) { + const date = new Date(dateString) + return format(date, "dd/MM/yy HH:mm", { locale: ptBR }) +} + +function formatLastSeen(lastSeen: string | null) { + if (!lastSeen) return "Nunca conectado" + return formatDate(lastSeen) +} + +export function AdminClientsManager({ initialClients }: { initialClients: AdminClient[] }) { + const [clients, setClients] = useState(initialClients) + const [search, setSearch] = useState("") + const [roleFilter, setRoleFilter] = useState<"all" | AdminClient["role"]>("all") + const [companyFilter, setCompanyFilter] = useState("all") + const [rowSelection, setRowSelection] = useState({}) + const [sorting, setSorting] = useState([{ id: "name", desc: false }]) + const [isPending, startTransition] = useTransition() + + const companies = useMemo(() => { + const entries = new Map() + clients.forEach((client) => { + if (client.companyId && client.companyName) { + entries.set(client.companyId, client.companyName) + } + }) + return Array.from(entries.entries()).map(([id, name]) => ({ id, name })) + }, [clients]) + + const filteredData = useMemo(() => { + return clients.filter((client) => { + if (roleFilter !== "all" && client.role !== roleFilter) return false + if (companyFilter !== "all" && client.companyId !== companyFilter) return false + if (!search.trim()) return true + const term = search.trim().toLowerCase() + return ( + client.name.toLowerCase().includes(term) || + client.email.toLowerCase().includes(term) || + (client.companyName ?? "").toLowerCase().includes(term) + ) + }) + }, [clients, roleFilter, companyFilter, search]) + + const handleDelete = useCallback( + (ids: string[]) => { + if (ids.length === 0) return + startTransition(async () => { + try { + const response = await fetch("/api/admin/clients", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }) + if (!response.ok) { + const payload = await response.json().catch(() => null) + throw new Error(payload?.error ?? "Não foi possível excluir os clientes selecionados.") + } + const { deletedIds } = (await response.json().catch(() => ({ deletedIds: [] }))) as { + deletedIds: string[] + } + if (deletedIds.length > 0) { + setClients((prev) => prev.filter((client) => !deletedIds.includes(client.id))) + setRowSelection({}) + } + toast.success( + deletedIds.length === 1 + ? "Cliente removido com sucesso." + : `${deletedIds.length} clientes removidos com sucesso.`, + ) + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Não foi possível excluir os clientes selecionados." + toast.error(message) + } + }) + }, + [startTransition], + ) + + const columns = useMemo[]>( + () => [ + { + id: "select", + header: ({ table }) => ( +
+ table.toggleAllPageRowsSelected(!!value)} + aria-label="Selecionar todos" + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} + aria-label="Selecionar linha" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "name", + header: "Cliente", + cell: ({ row }) => { + const client = row.original + const initials = client.name + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((value) => value.charAt(0).toUpperCase()) + .join("") + return ( +
+ + {initials || client.email.charAt(0).toUpperCase()} + +
+

{client.name}

+

{client.email}

+
+
+ ) + }, + }, + { + accessorKey: "role", + header: "Perfil", + cell: ({ row }) => { + const role = row.original.role + const variant = role === "MANAGER" ? "default" : "secondary" + return {ROLE_LABEL[role]} + }, + }, + { + accessorKey: "companyName", + header: "Empresa", + cell: ({ row }) => + row.original.companyName ? ( + + {row.original.companyName} + + ) : ( + Sem empresa + ), + }, + { + accessorKey: "createdAt", + header: "Cadastrado em", + cell: ({ row }) => ( + {formatDate(row.original.createdAt)} + ), + }, + { + id: "lastSeenAt", + header: "Último acesso", + cell: ({ row }) => ( + {formatLastSeen(row.original.lastSeenAt)} + ), + }, + { + id: "actions", + header: "", + enableSorting: false, + cell: ({ row }) => ( + + ), + }, + ], + [handleDelete, isPending] + ) + + const table = useReactTable({ + data: filteredData, + columns, + state: { rowSelection, sorting }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getRowId: (row) => row.id, + initialState: { + pagination: { + pageSize: 10, + }, + }, + }) + + const selectedRows = table.getSelectedRowModel().flatRows.map((row) => row.original) + + return ( +
+
+
+ + {clients.length} cliente{clients.length === 1 ? "" : "s"} +
+
+
+ setSearch(event.target.value)} + className="h-9 w-full md:w-72" + /> + +
+
+ + + +
+
+
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : header.column.columnDef.header instanceof Function + ? header.column.columnDef.header(header.getContext()) + : header.column.columnDef.header} + + ))} + + ))} + + + {table.getRowModel().rows.length === 0 ? ( + + + Nenhum cliente encontrado para os filtros selecionados. + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + )} + +
+
+ +
+
+ Página {table.getState().pagination.pageIndex + 1} de {table.getPageCount() || 1} +
+
+ + +
+
+
+ ) +} diff --git a/src/components/admin/companies/admin-companies-manager.tsx b/src/components/admin/companies/admin-companies-manager.tsx index 78145c8..bf26c82 100644 --- a/src/components/admin/companies/admin-companies-manager.tsx +++ b/src/components/admin/companies/admin-companies-manager.tsx @@ -1,6 +1,7 @@ "use client" import { useCallback, useEffect, useMemo, useRef, useState, useTransition, useId } from "react" +import Link from "next/link" import { formatDistanceToNow } from "date-fns" import { ptBR } from "date-fns/locale" import { useQuery } from "convex/react" @@ -11,6 +12,7 @@ import { IconCopy, IconDotsVertical, IconCheck, + IconDeviceDesktop, IconPencil, IconRefresh, IconSearch, @@ -73,6 +75,11 @@ type MachineSummary = { hostname: string status: string | null lastHeartbeatAt: number | null + isActive?: boolean | null + authEmail?: string | null + osName?: string | null + osVersion?: string | null + architecture?: string | null } export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) { @@ -84,6 +91,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: const [deleteId, setDeleteId] = useState(null) const [isDeleting, setIsDeleting] = useState(false) const [searchTerm, setSearchTerm] = useState("") + const [machinesDialog, setMachinesDialog] = useState<{ companyId: string; name: string } | null>(null) const isMobile = useIsMobile() const nameId = useId() @@ -111,6 +119,10 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: if (!editingId) return [] return machinesByCompanyId.get(editingId) ?? [] }, [machinesByCompanyId, editingId]) + const machinesDialogList = useMemo(() => { + if (!machinesDialog) return [] + return machinesByCompanyId.get(machinesDialog.companyId) ?? [] + }, [machinesByCompanyId, machinesDialog]) const resetForm = () => setForm({}) @@ -575,7 +587,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
{companyMachines.slice(0, 3).map((machine) => { - const variant = getMachineStatusVariant(machine.status) + const variant = getMachineStatusVariant(machine.isActive === false ? "deactivated" : machine.status) return ( @@ -661,15 +673,15 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: )}
) : ( -
+
- - - Empresa - Provisionamento - Cliente avulso - Uso e alertas - Ações + + + Empresa + Provisionamento + Cliente avulso + Uso e alertas + Ações @@ -683,6 +695,8 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: ? formatDistanceToNow(alertInfo.createdAt, { addSuffix: true, locale: ptBR }) : null const formattedPhone = formatPhoneDisplay(company.phone) + const companyMachines = machinesByCompanyId.get(company.id) ?? [] + const machineCount = companyMachines.length return ( ) : null} + + {machineCount} máquina{machineCount === 1 ? "" : "s"} +
@@ -803,6 +823,16 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: )} +
+
) @@ -845,6 +876,54 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
)} + { if (!open) setMachinesDialog(null) }}> + + + Máquinas — {machinesDialog?.name ?? ""} + + {machinesDialogList.length === 0 ? ( +

Nenhuma máquina vinculada a esta empresa.

+ ) : ( +
    + {machinesDialogList.map((machine) => { + const statusKey = machine.isActive === false ? "deactivated" : machine.status + const statusVariant = getMachineStatusVariant(statusKey) + return ( +
  • +
    +
    +

    {machine.hostname}

    +

    {machine.authEmail ?? "Sem e-mail definido"}

    +
    + + {statusVariant.label} + +
    +
    + {machine.osName ?? "SO desconhecido"} + {machine.osVersion ? : null} + {machine.osVersion ? {machine.osVersion} : null} + {machine.architecture ? ( + + {machine.architecture.toUpperCase()} + + ) : null} +
    +
    + + + Último sinal: {formatRelativeTimestamp(machine.lastHeartbeatAt) ?? "nunca"} + +
    +
  • + ) + })} +
+ )} +
+
@@ -892,6 +971,7 @@ const MACHINE_STATUS_VARIANTS: Record { return machines.reduce>((acc, machine) => { - const normalized = (machine.status ?? "unknown").toLowerCase() + const normalized = (machine.isActive === false ? "deactivated" : machine.status ?? "unknown").toLowerCase() acc[normalized] = (acc[normalized] ?? 0) + 1 return acc }, {}) diff --git a/src/components/admin/machines/admin-machines-overview.tsx b/src/components/admin/machines/admin-machines-overview.tsx index a27a47d..1ce3a47 100644 --- a/src/components/admin/machines/admin-machines-overview.tsx +++ b/src/components/admin/machines/admin-machines-overview.tsx @@ -1,11 +1,12 @@ "use client" -import { useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" +import type { ReactNode } from "react" import { useQuery } from "convex/react" import { format, formatDistanceToNowStrict } from "date-fns" import { ptBR } from "date-fns/locale" import { toast } from "sonner" -import { ClipboardCopy, ServerCog, Cpu, MemoryStick, Monitor, HardDrive, Pencil, ShieldCheck, ShieldAlert, Apple, Terminal } from "lucide-react" +import { ClipboardCopy, ServerCog, Cpu, MemoryStick, Monitor, HardDrive, Pencil, ShieldCheck, ShieldAlert, Apple, Terminal, Power, PlayCircle, Download } from "lucide-react" import { api } from "@/convex/_generated/api" import { Badge } from "@/components/ui/badge" @@ -24,7 +25,9 @@ import { TableRow, } from "@/components/ui/table" import { Separator } from "@/components/ui/separator" +import { ChartContainer } from "@/components/ui/chart" import { cn } from "@/lib/utils" +import { RadialBarChart, RadialBar, PolarAngleAxis } from "recharts" import Link from "next/link" import { useRouter } from "next/navigation" import { useAuth } from "@/lib/auth-client" @@ -567,6 +570,7 @@ export type MachinesQueryItem = { assignedUserName: string | null assignedUserRole: string | null status: string | null + isActive: boolean lastHeartbeatAt: number | null heartbeatAgeMs: number | null registeredBy: string | null @@ -611,6 +615,7 @@ const statusLabels: Record = { stale: "Sem sinal", maintenance: "Manutenção", blocked: "Bloqueada", + deactivated: "Desativada", unknown: "Desconhecida", } @@ -620,6 +625,7 @@ const statusClasses: Record = { stale: "border-slate-400/30 bg-slate-200 text-slate-700", maintenance: "border-amber-500/20 bg-amber-500/15 text-amber-600", blocked: "border-orange-500/20 bg-orange-500/15 text-orange-600", + deactivated: "border-slate-400/40 bg-slate-100 text-slate-600", unknown: "border-slate-300 bg-slate-200 text-slate-700", } @@ -707,7 +713,8 @@ function getStatusVariant(status?: string | null) { } } -function resolveMachineStatus(machine: { status?: string | null; lastHeartbeatAt?: number | null }): string { +function resolveMachineStatus(machine: { status?: string | null; lastHeartbeatAt?: number | null; isActive?: boolean | null }): string { + if (machine.isActive === false) return "deactivated" const manualStatus = (machine.status ?? "").toLowerCase() if (["maintenance", "blocked"].includes(manualStatus)) { return manualStatus @@ -871,6 +878,8 @@ function MachineStatusBadge({ status }: { status?: string | null }) { ? "bg-amber-500" : s === "blocked" ? "bg-orange-500" + : s === "deactivated" + ? "bg-slate-500" : "bg-slate-400" const ringClass = s === "online" @@ -881,6 +890,8 @@ function MachineStatusBadge({ status }: { status?: string | null }) { ? "bg-amber-400/30" : s === "blocked" ? "bg-orange-400/30" + : s === "deactivated" + ? "bg-slate-400/40" : "bg-slate-300/30" const isOnline = s === "online" @@ -961,6 +972,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) { ? windowsBaseboardRaw : null const windowsSerialNumber = windowsBaseboard ? readString(toRecord(windowsBaseboard) ?? {}, "SerialNumber", "serialNumber") : undefined + const isActive = machine?.isActive ?? true const windowsMemoryModules = useMemo(() => { if (Array.isArray(windowsMemoryModulesRaw)) return windowsMemoryModulesRaw if (windowsMemoryModulesRaw && typeof windowsMemoryModulesRaw === "object") return [windowsMemoryModulesRaw] @@ -1111,6 +1123,61 @@ export function MachineDetails({ machine }: MachineDetailsProps) { const personaLabel = collaborator?.role === "manager" ? "Gestor" : "Colaborador" + const summaryChips = useMemo(() => { + const chips: Array<{ key: string; label: string; value: string; icon: ReactNode; tone?: "warning" | "muted" }> = [] + const osName = machine?.osName ?? "Sistema desconhecido" + const osVersion = machine?.osVersion ?? windowsVersionLabel ?? "" + chips.push({ + key: "os", + label: "Sistema", + value: [osName, osVersion].filter(Boolean).join(" ").trim(), + icon: , + }) + if (machine?.architecture) { + chips.push({ + key: "arch", + label: "Arquitetura", + value: machine.architecture.toUpperCase(), + icon: , + }) + } + if (windowsBuildLabel) { + chips.push({ + key: "build", + label: "Build", + value: windowsBuildLabel, + icon: , + }) + } + if (windowsActivationStatus !== null && windowsActivationStatus !== undefined) { + chips.push({ + key: "activation", + label: "Licença", + value: windowsActivationStatus ? "Ativada" : "Não ativada", + icon: windowsActivationStatus ? : , + tone: windowsActivationStatus ? undefined : "warning", + }) + } + if (primaryGpu?.name) { + chips.push({ + key: "gpu", + label: "GPU principal", + value: `${primaryGpu.name}${typeof primaryGpu.memoryBytes === "number" ? ` · ${formatBytes(primaryGpu.memoryBytes)}` : ""}`, + icon: , + }) + } + if (collaborator?.email) { + const collaboratorValue = collaborator.name ? `${collaborator.name} · ${collaborator.email}` : collaborator.email + chips.push({ + key: "collaborator", + label: personaLabel, + value: collaboratorValue, + icon: , + }) + } + return chips + }, [machine?.osName, machine?.osVersion, machine?.architecture, windowsVersionLabel, windowsBuildLabel, windowsActivationStatus, primaryGpu, collaborator?.email, collaborator?.name, personaLabel]) + const companyName = (() => { if (!companies || !machine?.companySlug) return machine?.companySlug ?? null const found = companies.find((c) => c.slug === machine.companySlug) @@ -1131,6 +1198,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) { (machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator" ) const [savingAccess, setSavingAccess] = useState(false) + const [togglingActive, setTogglingActive] = useState(false) const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false) const jsonText = useMemo(() => { const payload = { @@ -1145,6 +1213,20 @@ export function MachineDetails({ machine }: MachineDetailsProps) { } return JSON.stringify(payload, null, 2) }, [machine, metrics, metadata]) + const handleDownloadInventory = useCallback(() => { + if (!machine) return + const safeHostname = machine.hostname.replace(/[^a-z0-9_-]/gi, "-").replace(/-{2,}/g, "-").toLowerCase() + const fileName = `${safeHostname || "machine"}_${machine.id}.json` + const blob = new Blob([jsonText], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + }, [jsonText, machine]) const filteredJsonHtml = useMemo(() => { if (!dialogQuery.trim()) return jsonText @@ -1200,6 +1282,29 @@ export function MachineDetails({ machine }: MachineDetailsProps) { } } + const handleToggleActive = async () => { + if (!machine) return + setTogglingActive(true) + try { + const response = await fetch("/api/admin/machines/toggle-active", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ machineId: machine.id, active: !isActive }), + credentials: "include", + }) + if (!response.ok) { + const payload = await response.json().catch(() => ({})) as { error?: string } + throw new Error(payload?.error ?? "Falha ao atualizar status") + } + toast.success(!isActive ? "Máquina reativada" : "Máquina desativada") + } catch (error) { + console.error(error) + toast.error("Não foi possível atualizar o status da máquina.") + } finally { + setTogglingActive(false) + } + } + return ( @@ -1224,55 +1329,26 @@ export function MachineDetails({ machine }: MachineDetailsProps) {

{machine.authEmail ?? "E-mail não definido"}

- {machine.companySlug ? ( -

- Empresa vinculada: {companyName ?? machine.companySlug} -

+ +
+ {companyName ? ( +
+ {companyName} +
+ ) : null} + + {!isActive ? ( + + Máquina desativada + ) : null}
- {/* ping integrado na badge de status */} -
- - - {machine.osName ?? "SO desconhecido"} {machine.osVersion ?? ""} - - - {machine.architecture?.toUpperCase() ?? "Arquitetura indefinida"} - - {windowsOsInfo ? ( - - Build: {windowsBuildLabel ?? "—"} - - ) : null} - {windowsOsInfo ? ( - - Ativado: { - windowsActivationStatus == null - ? "—" - : windowsActivationStatus - ? "Sim" - : "Não" - } - - ) : null} - {primaryGpu?.name ? ( - - GPU: {primaryGpu.name} - {typeof primaryGpu.memoryBytes === "number" ? ` · ${formatBytes(primaryGpu.memoryBytes)}` : ""} - - ) : null} - {companyName ? ( - - Empresa: {companyName} - - ) : null} - {collaborator?.email ? ( - - {personaLabel}: {collaborator?.name ? `${collaborator.name} · ` : ""}{collaborator.email} - - ) : null} +
+ {summaryChips.map((chip) => ( + + ))}
{machine.authEmail ? ( @@ -1285,6 +1361,19 @@ export function MachineDetails({ machine }: MachineDetailsProps) { Ajustar acesso + {machine.registeredBy ? ( Registrada via {machine.registeredBy} @@ -1405,12 +1494,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
- {metrics && typeof metrics === "object" ? (

Métricas recentes

- +
- ) : null} {hardware || network || (labels && labels.length > 0) ? (
@@ -2149,7 +2236,23 @@ export function MachineDetails({ machine }: MachineDetailsProps) { Inventário completo — {machine.hostname}
- setDialogQuery(e.target.value)} /> +
+ setDialogQuery(e.target.value)} + className="sm:flex-1" + /> + +
')
@@ -2225,7 +2328,7 @@ function MachinesGrid({ machines, companyNameBySlug }: { machines: MachinesQuery
 
 function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; companyName?: string | null }) {
   const effectiveStatus = resolveMachineStatus(machine)
-  const { className } = getStatusVariant(effectiveStatus)
+  const isActive = machine.isActive
   const lastHeartbeat = machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
   type AgentMetrics = {
     memoryUsedBytes?: number
@@ -2264,19 +2367,23 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
 
   return (
     
-      
+      
         
{effectiveStatus === "online" ? ( @@ -2288,6 +2395,11 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com {machine.hostname} {machine.authEmail ?? "—"} + {!isActive ? ( + + Desativada + + ) : null}
@@ -2351,52 +2463,243 @@ function DetailLine({ label, value, classNameValue }: DetailLineProps) { ) } -function MetricsGrid({ metrics }: { metrics: MachineMetrics }) { +function InfoChip({ label, value, icon, tone = "default" }: { label: string; value: string; icon?: ReactNode; tone?: "default" | "warning" | "muted" }) { + const toneClasses = + tone === "warning" + ? "border-amber-200 bg-amber-50 text-amber-700" + : tone === "muted" + ? "border-slate-200 bg-slate-50 text-neutral-600" + : "border-slate-200 bg-white text-neutral-800" + + return ( +
+ {icon ? {icon} : null} +
+

{label}

+

{value}

+
+
+ ) +} + +function clampPercent(raw: number): number { + if (!Number.isFinite(raw)) return 0 + const normalized = raw > 1 && raw <= 100 ? raw : raw <= 1 ? raw * 100 : raw + return Math.max(0, Math.min(100, normalized)) +} + +function deriveUsageMetrics({ + metrics, + hardware, + disks, +}: { + metrics: MachineMetrics + hardware?: MachineInventory["hardware"] + disks?: MachineInventory["disks"] +}) { const data = (metrics ?? {}) as Record - // Compat: aceitar chaves do agente desktop (cpuUsagePercent, memoryUsedBytes, memoryTotalBytes) - const cpu = (() => { - const v = Number( - data.cpuUsage ?? data.cpu ?? data.cpu_percent ?? data.cpuUsagePercent ?? NaN - ) - return v - })() - const memory = (() => { - // valor absoluto em bytes, se disponível - const memBytes = Number( - data.memoryBytes ?? data.memory ?? data.memory_used ?? data.memoryUsedBytes ?? NaN - ) - if (Number.isFinite(memBytes)) return memBytes - // tentar derivar a partir de percentuais do agente - const usedPct = Number(data.memoryUsedPercent ?? NaN) - const totalBytes = Number(data.memoryTotalBytes ?? NaN) - if (Number.isFinite(usedPct) && Number.isFinite(totalBytes)) { - return Math.max(0, Math.min(1, usedPct > 1 ? usedPct / 100 : usedPct)) * totalBytes + + const cpuRaw = Number( + data.cpuUsagePercent ?? data.cpuUsage ?? data.cpu_percent ?? data.cpu ?? NaN + ) + const cpuPercent = Number.isFinite(cpuRaw) ? clampPercent(cpuRaw) : null + + const totalCandidates = [ + data.memoryTotalBytes, + data.memory_total, + data.memoryTotal, + hardware?.memoryBytes, + hardware?.memory, + ] + let memoryTotalBytes: number | null = null + for (const candidate of totalCandidates) { + const parsed = parseBytesLike(candidate) + if (parsed && Number.isFinite(parsed) && parsed > 0) { + memoryTotalBytes = parsed + break } - return NaN - })() - const disk = Number(data.diskUsage ?? data.disk ?? NaN) - const gpuUsage = Number( - data.gpuUsage ?? data.gpu ?? data.gpuUsagePercent ?? data.gpu_percent ?? NaN + const numeric = Number(candidate) + if (Number.isFinite(numeric) && numeric > 0) { + memoryTotalBytes = numeric + break + } + } + + const usedCandidates = [ + data.memoryUsedBytes, + data.memoryBytes, + data.memory_used, + data.memory, + ] + let memoryUsedBytes: number | null = null + for (const candidate of usedCandidates) { + const parsed = parseBytesLike(candidate) + if (parsed !== undefined && Number.isFinite(parsed)) { + memoryUsedBytes = parsed + break + } + const numeric = Number(candidate) + if (Number.isFinite(numeric)) { + memoryUsedBytes = numeric + break + } + } + + const memoryPercentRaw = Number(data.memoryUsedPercent ?? data.memory_percent ?? NaN) + let memoryPercent = Number.isFinite(memoryPercentRaw) ? clampPercent(memoryPercentRaw) : null + if (memoryTotalBytes && memoryUsedBytes === null && memoryPercent !== null) { + memoryUsedBytes = (memoryPercent / 100) * memoryTotalBytes + } else if (memoryTotalBytes && memoryUsedBytes !== null) { + memoryPercent = clampPercent((memoryUsedBytes / memoryTotalBytes) * 100) + } + + let diskTotalBytes: number | null = null + let diskUsedBytes: number | null = null + let diskPercent: number | null = null + if (Array.isArray(disks) && disks.length > 0) { + let total = 0 + let available = 0 + disks.forEach((disk) => { + const totalParsed = parseBytesLike(disk?.totalBytes) + if (typeof totalParsed === "number" && Number.isFinite(totalParsed) && totalParsed > 0) { + total += totalParsed + } + const availableParsed = parseBytesLike(disk?.availableBytes) + if (typeof availableParsed === "number" && Number.isFinite(availableParsed) && availableParsed >= 0) { + available += availableParsed + } + }) + if (total > 0) { + diskTotalBytes = total + const used = Math.max(0, total - available) + diskUsedBytes = used + diskPercent = clampPercent((used / total) * 100) + } + } + if (diskPercent === null) { + const diskMetric = Number( + data.diskUsage ?? data.disk ?? data.diskUsedPercent ?? data.storageUsedPercent ?? NaN + ) + if (Number.isFinite(diskMetric)) { + diskPercent = clampPercent(diskMetric) + } + } + + const gpuMetric = Number( + data.gpuUsagePercent ?? data.gpuUsage ?? data.gpu_percent ?? data.gpu ?? NaN + ) + const gpuPercent = Number.isFinite(gpuMetric) ? clampPercent(gpuMetric) : null + + return { + cpuPercent, + memoryUsedBytes, + memoryTotalBytes, + memoryPercent, + diskPercent, + diskUsedBytes, + diskTotalBytes, + gpuPercent, + } +} + +function MetricsGrid({ metrics, hardware, disks }: { metrics: MachineMetrics; hardware?: MachineInventory["hardware"]; disks?: MachineInventory["disks"] }) { + const derived = useMemo( + () => deriveUsageMetrics({ metrics, hardware, disks }), + [metrics, hardware, disks] ) - const cards: Array<{ label: string; value: string }> = [ - { label: "CPU", value: formatPercent(cpu) }, - { label: "Memória", value: formatBytes(memory) }, - { label: "Disco", value: Number.isNaN(disk) ? "—" : formatPercent(disk) }, - ] + const cards = [ + { + key: "cpu", + label: "CPU", + percent: derived.cpuPercent, + primaryText: derived.cpuPercent !== null ? formatPercent(derived.cpuPercent) : "Sem dados", + secondaryText: derived.cpuPercent !== null ? "Uso instantâneo" : "Sem leituras recentes", + icon: , + color: "var(--chart-1)", + }, + { + key: "memory", + label: "Memória", + percent: derived.memoryPercent, + primaryText: + derived.memoryUsedBytes !== null && derived.memoryTotalBytes !== null + ? `${formatBytes(derived.memoryUsedBytes)} / ${formatBytes(derived.memoryTotalBytes)}` + : derived.memoryPercent !== null + ? formatPercent(derived.memoryPercent) + : "Sem dados", + secondaryText: derived.memoryPercent !== null ? `${Math.round(derived.memoryPercent)}% em uso` : null, + icon: , + color: "var(--chart-2)", + }, + { + key: "disk", + label: "Disco", + percent: derived.diskPercent, + primaryText: + derived.diskUsedBytes !== null && derived.diskTotalBytes !== null + ? `${formatBytes(derived.diskUsedBytes)} / ${formatBytes(derived.diskTotalBytes)}` + : derived.diskPercent !== null + ? formatPercent(derived.diskPercent) + : "Sem dados", + secondaryText: derived.diskPercent !== null ? `${Math.round(derived.diskPercent)}% utilizado` : null, + icon: , + color: "var(--chart-3)", + }, + ] as Array<{ key: string; label: string; percent: number | null; primaryText: string; secondaryText?: string | null; icon: ReactNode; color: string }> - if (!Number.isNaN(gpuUsage)) { - cards.push({ label: "GPU", value: formatPercent(gpuUsage) }) + if (derived.gpuPercent !== null) { + cards.push({ + key: "gpu", + label: "GPU", + percent: derived.gpuPercent, + primaryText: formatPercent(derived.gpuPercent), + secondaryText: null, + icon: , + color: "var(--chart-4)", + }) } return ( -
- {cards.map((card) => ( -
-

{card.label}

-

{card.value}

-
- ))} +
+ {cards.map((card) => { + const percentValue = Number.isFinite(card.percent ?? NaN) ? Math.max(0, Math.min(100, card.percent ?? 0)) : 0 + const percentLabel = card.percent !== null ? `${Math.round(card.percent)}%` : "—" + return ( +
+
+ + + + + + +
+ {percentLabel} +
+
+
+
+ {card.icon} + {card.label} +
+
{card.primaryText}
+ {card.secondaryText ? ( +
{card.secondaryText}
+ ) : null} +
+
+ ) + })}
) } diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index a4b4b0d..fdb0b0e 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -15,11 +15,11 @@ import { Clock4, Timer, MonitorCog, - Layers3, UserPlus, BellRing, ChevronDown, ShieldCheck, + Users, } from "lucide-react" import { usePathname } from "next/navigation" import Link from "next/link" @@ -86,7 +86,7 @@ const navigation: NavigationGroup[] = [ { title: "SLA e produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" }, { title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" }, { title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" }, - { title: "Horas por cliente", url: "/reports/hours", icon: Clock4, requiredRole: "staff" }, + { title: "Horas", url: "/reports/hours", icon: Clock4, requiredRole: "staff" }, ], }, { @@ -102,9 +102,14 @@ const navigation: NavigationGroup[] = [ }, { title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" }, { title: "Times & papéis", url: "/admin/teams", icon: UserCog, requiredRole: "admin" }, - { title: "Empresas & clientes", url: "/admin/companies", icon: Building2, requiredRole: "admin" }, + { + title: "Empresas", + url: "/admin/companies", + icon: Building2, + requiredRole: "admin", + children: [{ title: "Clientes", url: "/admin/clients", icon: Users, requiredRole: "admin" }], + }, { title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" }, - { title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" }, { title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" }, { title: "Alertas enviados", url: "/admin/alerts", icon: BellRing, requiredRole: "admin" }, ], diff --git a/src/components/charts/chart-opened-resolved.tsx b/src/components/charts/chart-opened-resolved.tsx index 4975112..278e56a 100644 --- a/src/components/charts/chart-opened-resolved.tsx +++ b/src/components/charts/chart-opened-resolved.tsx @@ -86,12 +86,12 @@ export function ChartOpenedResolved() { {data.series.length === 0 ? ( -
+
Sem dados suficientes no período selecionado.
) : ( - - + + } /> - - + + )} diff --git a/src/components/portal/portal-ticket-detail.tsx b/src/components/portal/portal-ticket-detail.tsx index 7a00520..ef93070 100644 --- a/src/components/portal/portal-ticket-detail.tsx +++ b/src/components/portal/portal-ticket-detail.tsx @@ -15,6 +15,7 @@ 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 { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dropzone } from "@/components/ui/dropzone" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" import { Skeleton } from "@/components/ui/skeleton" @@ -60,13 +61,26 @@ type ClientTimelineEntry = { } export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { - const { convexUserId, session, isCustomer } = useAuth() + const { convexUserId, session, isCustomer, machineContext } = useAuth() const addComment = useMutation(api.tickets.addComment) const getFileUrl = useAction(api.files.getUrl) const [comment, setComment] = useState("") const [attachments, setAttachments] = useState< Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }> >([]) + const attachmentsTotalBytes = useMemo( + () => attachments.reduce((acc, item) => acc + (item.size ?? 0), 0), + [attachments] + ) + const [previewAttachment, setPreviewAttachment] = useState<{ url: string; name: string; type?: string } | null>(null) + const isPreviewImage = useMemo(() => { + if (!previewAttachment) return false + const type = previewAttachment.type ?? "" + if (type.startsWith("image/")) return true + const name = previewAttachment.name ?? "" + return /\.(png|jpe?g|gif|webp|svg)$/i.test(name) + }, [previewAttachment]) + const machineInactive = machineContext?.isActive === false const ticketRaw = useQuery( api.tickets.getById, @@ -225,6 +239,10 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { const updatedAgo = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR }) async function handleSubmit(event: React.FormEvent) { event.preventDefault() + if (machineInactive) { + toast.error("Esta máquina está desativada. Reative-a para enviar novas mensagens.") + return + } if (!convexUserId || !comment.trim() || !ticket) return const toastId = "portal-add-comment" toast.loading("Enviando comentário...", { id: toastId }) @@ -303,8 +321,13 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { + {machineInactive ? ( +
+ Esta máquina está desativada. Ative-a novamente para enviar novas mensagens. +
+ ) : null}
-
+
@@ -313,12 +336,16 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { onChange={(html) => setComment(html)} placeholder="Descreva o que aconteceu, envie atualizações ou compartilhe novas informações." className="rounded-2xl border border-slate-200 shadow-sm focus-within:border-neutral-900 focus-within:ring-neutral-900/20" + disabled={machineInactive} />
setAttachments((prev) => [...prev, ...files])} className="rounded-xl border border-dashed border-slate-300 bg-slate-50 px-3 py-4 text-sm text-neutral-600 shadow-inner" + currentFileCount={attachments.length} + currentTotalBytes={attachmentsTotalBytes} + disabled={machineInactive} /> {attachments.length > 0 ? (
@@ -371,10 +398,9 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { })}
) : null} -

Máximo 5MB • Até 5 arquivos

-
@@ -429,6 +455,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { key={attachment.id} attachment={attachment} getFileUrl={getFileUrl} + onOpenPreview={(payload) => setPreviewAttachment(payload)} /> ))}
@@ -465,6 +492,38 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
+ { if (!open) setPreviewAttachment(null) }}> + + + + {previewAttachment?.name ?? "Visualização do anexo"} + + + + + + {previewAttachment ? ( + isPreviewImage ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {previewAttachment.name +
+ ) : ( +
+

Não é possível visualizar este tipo de arquivo aqui. Abra em uma nova aba para conferi-lo.

+ + Abrir em nova aba + +
+ ) + ) : null} +
+
) } @@ -488,7 +547,15 @@ function DetailItem({ label, value, subtitle }: DetailItemProps) { type CommentAttachment = TicketWithDetails["comments"][number]["attachments"][number] type GetFileUrlAction = (args: { storageId: Id<"_storage"> }) => Promise -function PortalCommentAttachmentCard({ attachment, getFileUrl }: { attachment: CommentAttachment; getFileUrl: GetFileUrlAction }) { +function PortalCommentAttachmentCard({ + attachment, + getFileUrl, + onOpenPreview, +}: { + attachment: CommentAttachment + getFileUrl: GetFileUrlAction + onOpenPreview: (payload: { url: string; name: string; type?: string }) => void +}) { const [url, setUrl] = useState(attachment.url ?? null) const [loading, setLoading] = useState(false) const [errored, setErrored] = useState(false) @@ -532,10 +599,13 @@ function PortalCommentAttachmentCard({ attachment, getFileUrl }: { attachment: C const handlePreview = useCallback(async () => { const target = await ensureUrl() - if (target) { - window.open(target, "_blank", "noopener,noreferrer") + if (!target) return + if (isImageType) { + onOpenPreview({ url: target, name: attachment.name ?? "Anexo", type: attachment.type ?? undefined }) + return } - }, [ensureUrl]) + window.open(target, "_blank", "noopener,noreferrer") + }, [attachment.name, attachment.type, ensureUrl, isImageType, onOpenPreview]) const handleDownload = useCallback(async () => { const target = await ensureUrl() diff --git a/src/components/portal/portal-ticket-list.tsx b/src/components/portal/portal-ticket-list.tsx index 6c3fbc6..3ae286c 100644 --- a/src/components/portal/portal-ticket-list.tsx +++ b/src/components/portal/portal-ticket-list.tsx @@ -41,12 +41,16 @@ export function PortalTicketList() { if (isLoading) { return ( - - - Carregando chamados... - - - Estamos buscando seus chamados mais recentes. Isso deve levar apenas alguns instantes. + +
+ +
+
+ Carregando chamados... +

+ Estamos buscando seus chamados mais recentes. Isso deve levar apenas alguns instantes. +

+
) diff --git a/src/components/reports/hours-report.tsx b/src/components/reports/hours-report.tsx index adf954d..50ed6f8 100644 --- a/src/components/reports/hours-report.tsx +++ b/src/components/reports/hours-report.tsx @@ -14,11 +14,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { usePersistentCompanyFilter } from "@/lib/use-company-filter" - -function formatHours(ms: number) { - const hours = ms / 3600000 - return hours.toFixed(2) -} +import { Progress } from "@/components/ui/progress" type HoursItem = { companyId: string @@ -52,16 +48,58 @@ export function HoursReport() { return list }, [data?.items, query, companyId]) + const totals = useMemo(() => { + return filtered.reduce( + (acc, item) => { + acc.internal += item.internalMs / 3600000 + acc.external += item.externalMs / 3600000 + acc.total += item.totalMs / 3600000 + return acc + }, + { internal: 0, external: 0, total: 0 } + ) + }, [filtered]) + + const numberFormatter = useMemo( + () => + new Intl.NumberFormat("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + [] + ) + + const filteredWithComputed = useMemo( + () => + filtered.map((row) => { + const internal = row.internalMs / 3600000 + const external = row.externalMs / 3600000 + const total = row.totalMs / 3600000 + const contracted = row.contractedHoursPerMonth ?? null + const usagePercent = + contracted && contracted > 0 ? Math.min(100, Math.round((total / contracted) * 100)) : null + return { + ...row, + internal, + external, + total, + contracted, + usagePercent, + } + }), + [filtered] + ) + return (
- Horas por cliente - Horas internas e externas registradas por empresa. + Horas + Visualize o esforço interno e externo por empresa e acompanhe o consumo contratado.
setQuery(e.target.value)} className="h-9 w-full min-w-56 sm:w-72" @@ -83,56 +121,91 @@ export function HoursReport() { 7 dias
-
-
- - - - - - - - - - - - - {filtered.map((row) => { - const totalH = Number(formatHours(row.totalMs)) - const contracted = row.contractedHoursPerMonth ?? null - const pct = contracted ? Math.round((totalH / contracted) * 100) : null - const pctBadgeVariant: "secondary" | "destructive" = pct !== null && pct >= 90 ? "destructive" : "secondary" - return ( - - - - - - - - - - ) - })} - -
ClienteAvulsoHoras internasHoras externasTotalContratadas/mêsUso
{row.name}{row.isAvulso ? "Sim" : "Não"}{formatHours(row.internalMs)}{formatHours(row.externalMs)}{formatHours(row.totalMs)}{contracted ?? "—"} - {pct !== null ? ( - - {pct}% - - ) : ( - - )} -
+
+ {[ + { key: "internal", label: "Horas internas", value: numberFormatter.format(totals.internal) }, + { key: "external", label: "Horas externas", value: numberFormatter.format(totals.external) }, + { key: "total", label: "Total acumulado", value: numberFormatter.format(totals.total) }, + ].map((item) => ( +
+

{item.label}

+

{item.value} h

+
+ ))}
+ + {filteredWithComputed.length === 0 ? ( +
+ Nenhuma empresa encontrada para o filtro selecionado. +
+ ) : ( +
+ {filteredWithComputed.map((row) => ( +
+
+
+

{row.name}

+

ID {row.companyId}

+
+ + {row.isAvulso ? "Cliente avulso" : "Recorrente"} + +
+
+
+ Horas internas + {numberFormatter.format(row.internal)} h +
+
+ Horas externas + {numberFormatter.format(row.external)} h +
+
+ Total + {numberFormatter.format(row.total)} h +
+
+
+
+ Contratadas/mês + + {row.contracted ? `${numberFormatter.format(row.contracted)} h` : "—"} + +
+
+
+ Uso + + {row.usagePercent !== null ? `${row.usagePercent}%` : "—"} + +
+ {row.usagePercent !== null ? ( + + ) : ( +
+ Defina horas contratadas para acompanhar o uso +
+ )} +
+
+
+ ))} +
+ )}
diff --git a/src/components/section-cards.tsx b/src/components/section-cards.tsx index 4d3b339..808ef67 100644 --- a/src/components/section-cards.tsx +++ b/src/components/section-cards.tsx @@ -2,11 +2,12 @@ import { useMemo } from "react" import { useQuery } from "convex/react" -import { IconClockHour4, IconMessages, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react" +import { IconClockHour4, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react" 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 { cn } from "@/lib/utils" import { Badge } from "@/components/ui/badge" import { Card, @@ -27,11 +28,6 @@ function formatMinutes(value: number | null) { return `${hours}h ${minutes}min` } -function formatScore(value: number | null) { - if (value === null) return "—" - return value.toFixed(2) -} - export function SectionCards() { const { session, convexUserId, isStaff } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID @@ -65,6 +61,29 @@ export function SectionCards() { const TrendIcon = trendInfo.icon + const resolutionInfo = useMemo(() => { + if (!dashboard?.resolution) { + return { + positive: true, + badgeLabel: "Sem histórico", + rateLabel: "Taxa indisponível", + } + } + const current = dashboard.resolution.resolvedLast7d ?? 0 + const previous = dashboard.resolution.previousResolved ?? 0 + const deltaPercentage = dashboard.resolution.deltaPercentage ?? null + const positive = deltaPercentage !== null ? deltaPercentage >= 0 : current >= previous + const badgeLabel = deltaPercentage !== null + ? `${deltaPercentage >= 0 ? "+" : ""}${deltaPercentage.toFixed(1)}%` + : previous > 0 + ? `${current - previous >= 0 ? "+" : ""}${current - previous}` + : "Sem histórico" + const rateLabel = dashboard.resolution.rate !== null + ? `${dashboard.resolution.rate.toFixed(1)}% dos tickets foram resolvidos` + : "Taxa indisponível" + return { positive, badgeLabel, rateLabel } + }, [dashboard]) + return (
@@ -150,20 +169,30 @@ export function SectionCards() { - CSAT recente + Tickets resolvidos (7 dias) - {dashboard ? formatScore(dashboard.csat.averageScore) : } + {dashboard ? dashboard.resolution.resolvedLast7d : } - - - {dashboard ? `${dashboard.csat.totalSurveys} pesquisas` : "—"} + + {resolutionInfo.positive ? ( + + ) : ( + + )} + {resolutionInfo.badgeLabel} - Notas de satisfação recebidas nos últimos períodos. - Escala de 1 a 5 pontos. + {resolutionInfo.rateLabel} + Comparação com os 7 dias anteriores.
diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index f48b205..554f65f 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -5,19 +5,26 @@ import { Separator } from "@/components/ui/separator" import { SidebarTrigger } from "@/components/ui/sidebar" import { cn } from "@/lib/utils" -interface SiteHeaderProps { - title: string - lead?: string - primaryAction?: ReactNode - secondaryAction?: ReactNode -} - +interface SiteHeaderProps { + title: string + lead?: string + primaryAction?: ReactNode + secondaryAction?: ReactNode + primaryAlignment?: "right" | "center" +} + export function SiteHeader({ title, lead, primaryAction, secondaryAction, + primaryAlignment = "right", }: SiteHeaderProps) { + const actionsClassName = + primaryAlignment === "center" && !secondaryAction + ? "flex w-full flex-col items-stretch gap-2 sm:w-full sm:flex-row sm:items-center sm:justify-center" + : "flex w-full flex-col items-stretch gap-2 sm:w-auto sm:flex-row sm:items-center" + return (
@@ -26,7 +33,7 @@ export function SiteHeader({ {lead ? {lead} : null}

{title}

-
+
{secondaryAction} {primaryAction}
diff --git a/src/components/tickets/new-ticket-dialog.client.tsx b/src/components/tickets/new-ticket-dialog.client.tsx index df315bb..f4f07ee 100644 --- a/src/components/tickets/new-ticket-dialog.client.tsx +++ b/src/components/tickets/new-ticket-dialog.client.tsx @@ -3,10 +3,11 @@ import { useEffect, useState } from "react" import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" import { NewTicketDialog } from "./new-ticket-dialog" -export function NewTicketDialogDeferred() { +export function NewTicketDialogDeferred({ triggerClassName }: { triggerClassName?: string } = {}) { const [mounted, setMounted] = useState(false) useEffect(() => { @@ -17,7 +18,10 @@ export function NewTicketDialogDeferred() { return ( @@ -279,6 +287,8 @@ export function NewTicketDialog() { 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" + currentFileCount={attachments.length} + currentTotalBytes={attachmentsTotalBytes} /> Formatos comuns de imagem e documentos são aceitos. diff --git a/src/components/tickets/recent-tickets-panel.tsx b/src/components/tickets/recent-tickets-panel.tsx index db5f2ce..b35a033 100644 --- a/src/components/tickets/recent-tickets-panel.tsx +++ b/src/components/tickets/recent-tickets-panel.tsx @@ -49,7 +49,7 @@ function TicketRow({ ticket, entering }: { ticket: Ticket; entering: boolean }) #{ticket.reference} {queueLabel}
-
+
diff --git a/src/components/tickets/ticket-comments.rich.tsx b/src/components/tickets/ticket-comments.rich.tsx index b725175..9d6fbbb 100644 --- a/src/components/tickets/ticket-comments.rich.tsx +++ b/src/components/tickets/ticket-comments.rich.tsx @@ -41,6 +41,10 @@ export function TicketComments({ ticket }: TicketCommentsProps) { const updateComment = useMutation(api.tickets.updateComment) const [body, setBody] = useState("") const [attachmentsToSend, setAttachmentsToSend] = useState>([]) + const attachmentsToSendTotalBytes = useMemo( + () => attachmentsToSend.reduce((acc, item) => acc + (item.size ?? 0), 0), + [attachmentsToSend] + ) const [preview, setPreview] = useState(null) const [pending, setPending] = useState[]>([]) const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("INTERNAL") @@ -358,7 +362,11 @@ export function TicketComments({ ticket }: TicketCommentsProps) { )} - setAttachmentsToSend((prev) => [...prev, ...files])} /> + setAttachmentsToSend((prev) => [...prev, ...files])} + currentFileCount={attachmentsToSend.length} + currentTotalBytes={attachmentsToSendTotalBytes} + /> {attachmentsToSend.length > 0 ? (
{attachmentsToSend.map((attachment, index) => { diff --git a/src/components/ui/dropzone.tsx b/src/components/ui/dropzone.tsx index 07830c3..de491b6 100644 --- a/src/components/ui/dropzone.tsx +++ b/src/components/ui/dropzone.tsx @@ -2,7 +2,7 @@ import { useAction } from "convex/react"; import { api } from "@/convex/_generated/api"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { Spinner } from "@/components/ui/spinner"; import { Upload, Check, X, AlertCircle } from "lucide-react"; @@ -16,12 +16,18 @@ export function Dropzone({ maxSize = 5 * 1024 * 1024, multiple = true, className, + currentFileCount = 0, + currentTotalBytes = 0, + disabled = false, }: { onUploaded?: (files: Uploaded[]) => void; maxFiles?: number; maxSize?: number; multiple?: boolean; className?: string; + currentFileCount?: number; + currentTotalBytes?: number; + disabled?: boolean; }) { const generateUrl = useAction(api.files.generateUploadUrl); const inputRef = useRef(null); @@ -30,8 +36,29 @@ export function Dropzone({ Array<{ id: string; name: string; progress: number; status: "uploading" | "done" | "error" }> >([]); + const normalizedFileCount = Math.max(0, currentFileCount); + const normalizedTotalBytes = Math.max(0, currentTotalBytes); + const remainingSlots = Math.max(0, maxFiles - normalizedFileCount); + const perFileLimitMb = Math.max(1, Math.round(maxSize / (1024 * 1024))); + + const infoMessage = useMemo(() => { + if (normalizedFileCount === 0) { + return `Cada arquivo com até ${perFileLimitMb}MB • Você pode anexar até ${maxFiles} ${maxFiles === 1 ? "arquivo" : "arquivos"}`; + } + const totalLabel = normalizedTotalBytes > 0 ? ` (${formatBytes(normalizedTotalBytes)})` : ""; + if (remainingSlots === 0) { + return `Limite atingido: ${normalizedFileCount}/${maxFiles} arquivos anexados${totalLabel}`; + } + return `${normalizedFileCount}/${maxFiles} arquivos anexados${totalLabel} • Restam ${remainingSlots} ${remainingSlots === 1 ? "arquivo" : "arquivos"}`; + }, [normalizedFileCount, normalizedTotalBytes, remainingSlots, maxFiles, perFileLimitMb]); + const startUpload = useCallback(async (files: FileList | File[]) => { - const list = Array.from(files).slice(0, maxFiles); + if (disabled) return; + const availableSlots = Math.max(0, maxFiles - normalizedFileCount); + if (availableSlots <= 0) { + return; + } + const list = Array.from(files).slice(0, availableSlots); const uploaded: Uploaded[] = []; for (const file of list) { if (file.size > maxSize) { @@ -82,34 +109,59 @@ export function Dropzone({ }); } if (uploaded.length) onUploaded?.(uploaded); - }, [generateUrl, maxFiles, maxSize, onUploaded]); + }, [disabled, generateUrl, maxFiles, maxSize, normalizedFileCount, onUploaded]); return (
inputRef.current?.click()} + onClick={() => { + if (disabled) return + inputRef.current?.click() + }} onKeyDown={(event) => { + if (disabled) return 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); }} + onDragEnter={(e) => { + if (disabled) return + e.preventDefault() + setDrag(true) + }} + onDragOver={(e) => { + if (disabled) return + e.preventDefault() + setDrag(true) + }} + onDragLeave={(e) => { + if (disabled) return + e.preventDefault() + setDrag(false) + }} + onDrop={(e) => { + if (disabled) return + e.preventDefault() + setDrag(false) + startUpload(e.dataTransfer.files) + }} > e.target.files && startUpload(e.target.files)} />
@@ -123,7 +175,7 @@ export function Dropzone({

Arraste arquivos aqui ou selecione

-

Máximo {Math.round(maxSize/1024/1024)}MB • Até {maxFiles} arquivos

+

{infoMessage}

{items.length > 0 && ( @@ -178,3 +230,16 @@ export function Dropzone({
) } + +function formatBytes(value: number) { + if (!Number.isFinite(value) || value <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = value; + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + const precision = size >= 10 || unitIndex === 0 ? 0 : 1; + return `${size.toFixed(precision)} ${units[unitIndex]}`; +} diff --git a/src/lib/auth-client.tsx b/src/lib/auth-client.tsx index 8b4920e..4e0c820 100644 --- a/src/lib/auth-client.tsx +++ b/src/lib/auth-client.tsx @@ -35,6 +35,7 @@ type MachineContext = { assignedUserName: string | null assignedUserRole: string | null companyId: string | null + isActive: boolean } type MachineContextError = { @@ -171,6 +172,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { assignedUserName: string | null assignedUserRole: string | null companyId: string | null + isActive?: boolean } setMachineContext({ machineId: machine.id, @@ -181,6 +183,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { assignedUserName: machine.assignedUserName ?? null, assignedUserRole: machine.assignedUserRole ?? null, companyId: machine.companyId ?? null, + isActive: machine.isActive ?? true, }) setMachineContextError(null) } diff --git a/src/server/machines-auth.ts b/src/server/machines-auth.ts index e1f49df..dbacfad 100644 --- a/src/server/machines-auth.ts +++ b/src/server/machines-auth.ts @@ -68,12 +68,15 @@ type EnsureCollaboratorAccountParams = { name: string tenantId: string companyId?: string | null + role?: "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR" } export async function ensureCollaboratorAccount(params: EnsureCollaboratorAccountParams) { const normalizedEmail = params.email.trim().toLowerCase() const name = params.name.trim() || normalizedEmail const tenantId = params.tenantId + const targetRole = (params.role ?? "COLLABORATOR").toUpperCase() as "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR" + const authRole = targetRole.toLowerCase() const existingAuth = await prisma.authUser.findUnique({ where: { email: normalizedEmail } }) const authUser = existingAuth @@ -82,7 +85,7 @@ export async function ensureCollaboratorAccount(params: EnsureCollaboratorAccoun data: { name, tenantId, - role: "collaborator", + role: authRole, }, }) : await prisma.authUser.create({ @@ -90,7 +93,7 @@ export async function ensureCollaboratorAccount(params: EnsureCollaboratorAccoun email: normalizedEmail, name, tenantId, - role: "collaborator", + role: authRole, }, }) @@ -117,14 +120,14 @@ export async function ensureCollaboratorAccount(params: EnsureCollaboratorAccoun update: { name, tenantId, - role: "COLLABORATOR", + role: targetRole, companyId: params.companyId ?? undefined, }, create: { email: normalizedEmail, name, tenantId, - role: "COLLABORATOR", + role: targetRole, companyId: params.companyId ?? undefined, }, })