Atualiza portal e admin com bloqueio de máquinas desativadas
This commit is contained in:
parent
e5085962e9
commit
630110bf3a
31 changed files with 1756 additions and 244 deletions
43
apps/desktop/src/components/DeactivationScreen.tsx
Normal file
43
apps/desktop/src/components/DeactivationScreen.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import React from "react"
|
||||||
|
import { RefreshCw, Mail } from "lucide-react"
|
||||||
|
|
||||||
|
export function DeactivationScreen({ companyName }: { companyName?: string | null }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen grid place-items-center bg-slate-100 p-6">
|
||||||
|
<div className="flex flex-col items-center gap-5 rounded-3xl border border-slate-200 bg-white px-8 py-10 shadow-xl">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-neutral-900 text-white shadow-lg">
|
||||||
|
<RefreshCw className="size-7 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Máquina desativada</h1>
|
||||||
|
<p className="max-w-sm text-sm text-neutral-600">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
{companyName ? (
|
||||||
|
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-neutral-700">
|
||||||
|
{companyName}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="w-full max-w-[360px] space-y-3 text-sm text-neutral-600">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<p className="font-medium text-neutral-800">Como proceder?</p>
|
||||||
|
<ul className="mt-2 space-y-2 text-neutral-600">
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-neutral-800">1.</span> Caso precise restaurar o acesso, entre em contato com a equipe de suporte da Rever.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-semibold text-neutral-800">2.</span> Informe o identificador desta máquina e peça a reativação.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-2 rounded-2xl border border-dashed border-neutral-300 bg-neutral-50 px-4 py-3 text-center text-xs text-neutral-500">
|
||||||
|
<Mail className="size-4" />
|
||||||
|
suporte@rever.com.br
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -440,6 +440,7 @@ export const register = mutation({
|
||||||
lastHeartbeatAt: now,
|
lastHeartbeatAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
status: "online",
|
status: "online",
|
||||||
|
isActive: true,
|
||||||
registeredBy: args.registeredBy ?? existing.registeredBy,
|
registeredBy: args.registeredBy ?? existing.registeredBy,
|
||||||
persona: existing.persona,
|
persona: existing.persona,
|
||||||
assignedUserId: existing.assignedUserId,
|
assignedUserId: existing.assignedUserId,
|
||||||
|
|
@ -463,6 +464,7 @@ export const register = mutation({
|
||||||
metadata: metadataPatch ? mergeMetadata(undefined, metadataPatch) : undefined,
|
metadata: metadataPatch ? mergeMetadata(undefined, metadataPatch) : undefined,
|
||||||
lastHeartbeatAt: now,
|
lastHeartbeatAt: now,
|
||||||
status: "online",
|
status: "online",
|
||||||
|
isActive: true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
registeredBy: args.registeredBy,
|
registeredBy: args.registeredBy,
|
||||||
|
|
@ -716,6 +718,7 @@ export const resolveToken = mutation({
|
||||||
status: machine.status,
|
status: machine.status,
|
||||||
lastHeartbeatAt: machine.lastHeartbeatAt,
|
lastHeartbeatAt: machine.lastHeartbeatAt,
|
||||||
metadata: machine.metadata,
|
metadata: machine.metadata,
|
||||||
|
isActive: machine.isActive ?? true,
|
||||||
},
|
},
|
||||||
token: {
|
token: {
|
||||||
expiresAt: token.expiresAt,
|
expiresAt: token.expiresAt,
|
||||||
|
|
@ -753,7 +756,9 @@ export const listByTenant = query({
|
||||||
const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs)
|
const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs)
|
||||||
const manualStatus = (machine.status ?? "").toLowerCase()
|
const manualStatus = (machine.status ?? "").toLowerCase()
|
||||||
let derivedStatus: string
|
let derivedStatus: string
|
||||||
if (["maintenance", "blocked"].includes(manualStatus)) {
|
if (machine.isActive === false) {
|
||||||
|
derivedStatus = "deactivated"
|
||||||
|
} else if (["maintenance", "blocked"].includes(manualStatus)) {
|
||||||
derivedStatus = manualStatus
|
derivedStatus = manualStatus
|
||||||
} else if (machine.lastHeartbeatAt) {
|
} else if (machine.lastHeartbeatAt) {
|
||||||
const age = now - machine.lastHeartbeatAt
|
const age = now - machine.lastHeartbeatAt
|
||||||
|
|
@ -810,6 +815,7 @@ export const listByTenant = query({
|
||||||
assignedUserName: machine.assignedUserName ?? null,
|
assignedUserName: machine.assignedUserName ?? null,
|
||||||
assignedUserRole: machine.assignedUserRole ?? null,
|
assignedUserRole: machine.assignedUserRole ?? null,
|
||||||
status: derivedStatus,
|
status: derivedStatus,
|
||||||
|
isActive: machine.isActive ?? true,
|
||||||
lastHeartbeatAt: machine.lastHeartbeatAt ?? null,
|
lastHeartbeatAt: machine.lastHeartbeatAt ?? null,
|
||||||
heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null,
|
heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null,
|
||||||
registeredBy: machine.registeredBy ?? null,
|
registeredBy: machine.registeredBy ?? null,
|
||||||
|
|
@ -988,6 +994,7 @@ export const getContext = query({
|
||||||
assignedUserRole: machine.assignedUserRole ?? null,
|
assignedUserRole: machine.assignedUserRole ?? null,
|
||||||
metadata: machine.metadata ?? null,
|
metadata: machine.metadata ?? null,
|
||||||
authEmail: machine.authEmail ?? 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({
|
export const remove = mutation({
|
||||||
args: {
|
args: {
|
||||||
machineId: v.id("machines"),
|
machineId: v.id("machines"),
|
||||||
|
|
|
||||||
|
|
@ -375,8 +375,20 @@ export const dashboardOverview = query({
|
||||||
const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
|
const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
|
||||||
const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
|
const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
|
||||||
|
|
||||||
const surveys = await collectCsatSurveys(ctx, tickets);
|
const resolvedLastWindow = tickets.filter(
|
||||||
const averageScore = average(surveys.map((item) => item.score));
|
(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 {
|
return {
|
||||||
newTickets: {
|
newTickets: {
|
||||||
|
|
@ -394,9 +406,11 @@ export const dashboardOverview = query({
|
||||||
total: awaitingTickets.length,
|
total: awaitingTickets.length,
|
||||||
atRisk: atRiskTickets.length,
|
atRisk: atRiskTickets.length,
|
||||||
},
|
},
|
||||||
csat: {
|
resolution: {
|
||||||
averageScore,
|
resolvedLast7d: resolvedLastWindow.length,
|
||||||
totalSurveys: surveys.length,
|
previousResolved: resolvedPreviousWindow.length,
|
||||||
|
rate: resolutionRate,
|
||||||
|
deltaPercentage: resolutionDelta,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,7 @@ export default defineSchema({
|
||||||
metadata: v.optional(v.any()),
|
metadata: v.optional(v.any()),
|
||||||
lastHeartbeatAt: v.optional(v.number()),
|
lastHeartbeatAt: v.optional(v.number()),
|
||||||
status: v.optional(v.string()),
|
status: v.optional(v.string()),
|
||||||
|
isActive: v.optional(v.boolean()),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
registeredBy: v.optional(v.string()),
|
registeredBy: v.optional(v.string()),
|
||||||
|
|
|
||||||
87
src/app/admin/clients/page.tsx
Normal file
87
src/app/admin/clients/page.tsx
Normal file
|
|
@ -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<string, Date>()
|
||||||
|
for (const sessionRow of sessions) {
|
||||||
|
if (!sessionByUserId.has(sessionRow.userId)) {
|
||||||
|
sessionByUserId.set(sessionRow.userId, sessionRow.updatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authByEmail = new Map<string, { id: string; updatedAt: Date; createdAt: Date }>()
|
||||||
|
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 (
|
||||||
|
<AppShell
|
||||||
|
header={<SiteHeader title="Clientes" lead="Gerencie colaboradores e gestores vinculados às empresas." />}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-7xl px-4 pb-12 lg:px-8">
|
||||||
|
<AdminClientsManager initialClients={initialClients} />
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -28,10 +28,7 @@ export default async function AdminCompaniesPage() {
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={
|
header={
|
||||||
<SiteHeader
|
<SiteHeader title="Empresas" lead="Gerencie os dados cadastrais das empresas atendidas." />
|
||||||
title="Empresas & Clientes"
|
|
||||||
lead="Gerencie os dados das empresas e controle o faturamento de clientes avulsos."
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-7xl px-4 md:px-8 lg:px-10">
|
<div className="mx-auto w-full max-w-7xl px-4 md:px-8 lg:px-10">
|
||||||
|
|
|
||||||
156
src/app/api/admin/clients/route.ts
Normal file
156
src/app/api/admin/clients/route.ts
Normal file
|
|
@ -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<string, Date>()
|
||||||
|
for (const sessionRow of sessions) {
|
||||||
|
if (!sessionByUserId.has(sessionRow.userId)) {
|
||||||
|
sessionByUserId.set(sessionRow.userId, sessionRow.updatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authByEmail = new Map<string, { id: string; updatedAt: Date; createdAt: Date }>()
|
||||||
|
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) })
|
||||||
|
}
|
||||||
61
src/app/api/admin/machines/toggle-active/route.ts
Normal file
61
src/app/api/admin/machines/toggle-active/route.ts
Normal file
|
|
@ -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<unknown> }
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/api/auth/get-session/route.ts
Normal file
37
src/app/api/auth/get-session/route.ts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -160,6 +160,7 @@ export async function POST(request: Request) {
|
||||||
name: collaborator.name ?? collaborator.email,
|
name: collaborator.name ?? collaborator.email,
|
||||||
tenantId,
|
tenantId,
|
||||||
companyId: companyRecord.id,
|
companyId: companyRecord.id,
|
||||||
|
role: persona === "manager" ? "MANAGER" : "COLLABORATOR",
|
||||||
})
|
})
|
||||||
|
|
||||||
if (persona) {
|
if (persona) {
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,25 @@ const updateSchema = z.object({
|
||||||
|
|
||||||
export async function PATCH(request: Request) {
|
export async function PATCH(request: Request) {
|
||||||
const session = await requireAuthenticatedSession()
|
const session = await requireAuthenticatedSession()
|
||||||
const role = (session.user.role ?? "").toLowerCase()
|
const normalizedRole = (session.user.role ?? "").toLowerCase()
|
||||||
if (role !== "collaborator" && role !== "manager") {
|
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 })
|
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
|
let payload: unknown
|
||||||
try {
|
try {
|
||||||
|
|
@ -132,14 +147,14 @@ export async function PATCH(request: Request) {
|
||||||
update: {
|
update: {
|
||||||
name,
|
name,
|
||||||
tenantId,
|
tenantId,
|
||||||
role: role === "manager" ? "MANAGER" : "COLLABORATOR",
|
role: effectiveRole,
|
||||||
companyId: companyId ?? undefined,
|
companyId: companyId ?? undefined,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
email: effectiveEmail,
|
email: effectiveEmail,
|
||||||
name,
|
name,
|
||||||
tenantId,
|
tenantId,
|
||||||
role: role === "manager" ? "MANAGER" : "COLLABORATOR",
|
role: effectiveRole,
|
||||||
companyId: companyId ?? undefined,
|
companyId: companyId ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -149,6 +164,7 @@ export async function PATCH(request: Request) {
|
||||||
name,
|
name,
|
||||||
tenantId,
|
tenantId,
|
||||||
companyId,
|
companyId,
|
||||||
|
role: effectiveRole,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (env.NEXT_PUBLIC_CONVEX_URL) {
|
if (env.NEXT_PUBLIC_CONVEX_URL) {
|
||||||
|
|
@ -158,7 +174,7 @@ export async function PATCH(request: Request) {
|
||||||
tenantId,
|
tenantId,
|
||||||
email: effectiveEmail,
|
email: effectiveEmail,
|
||||||
name,
|
name,
|
||||||
role: role === "manager" ? "MANAGER" : "COLLABORATOR",
|
role: effectiveRole,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[portal.profile] Falha ao sincronizar usuário no Convex", error)
|
console.warn("[portal.profile] Falha ao sincronizar usuário no Convex", error)
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,12 @@ export default async function Dashboard() {
|
||||||
<SiteHeader
|
<SiteHeader
|
||||||
title="Central de operações"
|
title="Central de operações"
|
||||||
lead="Monitoramento em tempo real"
|
lead="Monitoramento em tempo real"
|
||||||
secondaryAction={<SiteHeader.SecondaryButton>Modo play</SiteHeader.SecondaryButton>}
|
primaryAlignment="center"
|
||||||
primaryAction={<NewTicketDialogDeferred />}
|
primaryAction={
|
||||||
|
<div className="flex w-full justify-center sm:w-auto">
|
||||||
|
<NewTicketDialogDeferred triggerClassName="w-full max-w-xs text-base sm:w-auto sm:px-6 sm:py-2" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ export default async function PortalProfilePage() {
|
||||||
const session = await requireAuthenticatedSession()
|
const session = await requireAuthenticatedSession()
|
||||||
const role = (session.user.role ?? "").toLowerCase()
|
const role = (session.user.role ?? "").toLowerCase()
|
||||||
const persona = (session.user.machinePersona ?? "").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) {
|
if (!allowed) {
|
||||||
redirect("/portal")
|
redirect("/portal")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ export default async function ReportsHoursPage() {
|
||||||
<AppShell
|
<AppShell
|
||||||
header={
|
header={
|
||||||
<SiteHeader
|
<SiteHeader
|
||||||
title="Horas por cliente"
|
title="Horas"
|
||||||
lead="Acompanhe horas internas/externas por empresa e compare com a meta mensal."
|
lead="Acompanhe horas internas e externas por empresa e compare com a meta mensal contratada."
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
391
src/components/admin/clients/admin-clients-manager.tsx
Normal file
391
src/components/admin/clients/admin-clients-manager.tsx
Normal file
|
|
@ -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<AdminClient["role"], string> = {
|
||||||
|
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<string>("all")
|
||||||
|
const [rowSelection, setRowSelection] = useState({})
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }])
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const companies = useMemo(() => {
|
||||||
|
const entries = new Map<string, string>()
|
||||||
|
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<ColumnDef<AdminClient>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
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="Selecionar todos"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label="Selecionar linha"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="size-9 border border-slate-200">
|
||||||
|
<AvatarFallback>{initials || client.email.charAt(0).toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-neutral-900">{client.name}</p>
|
||||||
|
<p className="truncate text-xs text-neutral-500">{client.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "role",
|
||||||
|
header: "Perfil",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const role = row.original.role
|
||||||
|
const variant = role === "MANAGER" ? "default" : "secondary"
|
||||||
|
return <Badge variant={variant}>{ROLE_LABEL[role]}</Badge>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "companyName",
|
||||||
|
header: "Empresa",
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.companyName ? (
|
||||||
|
<Badge variant="outline" className="bg-slate-50 text-xs font-medium">
|
||||||
|
{row.original.companyName}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-neutral-500">Sem empresa</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: "Cadastrado em",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-neutral-600">{formatDate(row.original.createdAt)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lastSeenAt",
|
||||||
|
header: "Último acesso",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-neutral-600">{formatLastSeen(row.original.lastSeenAt)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => handleDelete([row.original.id])}
|
||||||
|
>
|
||||||
|
<IconTrash className="mr-2 size-4" /> Remover
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
||||||
|
<IconUser className="size-4" />
|
||||||
|
{clients.length} cliente{clients.length === 1 ? "" : "s"}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar por nome, e-mail ou empresa"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
className="h-9 w-full md:w-72"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="icon" className="md:hidden">
|
||||||
|
<IconFilter className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Select value={roleFilter} onValueChange={(value) => setRoleFilter(value as typeof roleFilter)}>
|
||||||
|
<SelectTrigger className="h-9 w-40">
|
||||||
|
<SelectValue placeholder="Perfil" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos os perfis</SelectItem>
|
||||||
|
<SelectItem value="MANAGER">Gestores</SelectItem>
|
||||||
|
<SelectItem value="COLLABORATOR">Colaboradores</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={companyFilter} onValueChange={(value) => setCompanyFilter(value)}>
|
||||||
|
<SelectTrigger className="h-9 w-48">
|
||||||
|
<SelectValue placeholder="Empresa" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-64">
|
||||||
|
<SelectItem value="all">Todas as empresas</SelectItem>
|
||||||
|
{companies.map((company) => (
|
||||||
|
<SelectItem key={company.id} value={company.id}>
|
||||||
|
{company.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={selectedRows.length === 0 || isPending}
|
||||||
|
onClick={() => handleDelete(selectedRows.map((row) => row.id))}
|
||||||
|
>
|
||||||
|
<IconTrash className="size-4" />
|
||||||
|
Excluir selecionados
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-slate-50">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id} className="border-slate-200">
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id} className="text-xs uppercase tracking-wide text-neutral-500">
|
||||||
|
{header.isPlaceholder ? null : header.column.columnDef.header instanceof Function
|
||||||
|
? header.column.columnDef.header(header.getContext())
|
||||||
|
: header.column.columnDef.header}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center text-sm text-neutral-500">
|
||||||
|
Nenhum cliente encontrado para os filtros selecionados.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id} className="align-middle text-sm text-neutral-700">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-between gap-3 text-sm text-neutral-600 md:flex-row">
|
||||||
|
<div>
|
||||||
|
Página {table.getState().pagination.pageIndex + 1} de {table.getPageCount() || 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<IconChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<IconChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, useTransition, useId } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState, useTransition, useId } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
import { formatDistanceToNow } from "date-fns"
|
import { formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
|
|
@ -11,6 +12,7 @@ import {
|
||||||
IconCopy,
|
IconCopy,
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
|
IconDeviceDesktop,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
|
|
@ -73,6 +75,11 @@ type MachineSummary = {
|
||||||
hostname: string
|
hostname: string
|
||||||
status: string | null
|
status: string | null
|
||||||
lastHeartbeatAt: number | 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[] }) {
|
export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) {
|
||||||
|
|
@ -84,6 +91,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [searchTerm, setSearchTerm] = useState("")
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
|
const [machinesDialog, setMachinesDialog] = useState<{ companyId: string; name: string } | null>(null)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const nameId = useId()
|
const nameId = useId()
|
||||||
|
|
@ -111,6 +119,10 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
if (!editingId) return []
|
if (!editingId) return []
|
||||||
return machinesByCompanyId.get(editingId) ?? []
|
return machinesByCompanyId.get(editingId) ?? []
|
||||||
}, [machinesByCompanyId, editingId])
|
}, [machinesByCompanyId, editingId])
|
||||||
|
const machinesDialogList = useMemo(() => {
|
||||||
|
if (!machinesDialog) return []
|
||||||
|
return machinesByCompanyId.get(machinesDialog.companyId) ?? []
|
||||||
|
}, [machinesByCompanyId, machinesDialog])
|
||||||
|
|
||||||
const resetForm = () => setForm({})
|
const resetForm = () => setForm({})
|
||||||
|
|
||||||
|
|
@ -575,7 +587,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{companyMachines.slice(0, 3).map((machine) => {
|
{companyMachines.slice(0, 3).map((machine) => {
|
||||||
const variant = getMachineStatusVariant(machine.status)
|
const variant = getMachineStatusVariant(machine.isActive === false ? "deactivated" : machine.status)
|
||||||
return (
|
return (
|
||||||
<Tooltip key={machine.id}>
|
<Tooltip key={machine.id}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -661,15 +673,15 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||||
<Table className="min-w-full table-fixed text-sm">
|
<Table className="min-w-full table-fixed text-sm">
|
||||||
<TableHeader>
|
<TableHeader className="sticky top-0 z-10 bg-muted/60 backdrop-blur supports-[backdrop-filter]:bg-muted/40">
|
||||||
<TableRow className="border-slate-100/80 dark:border-slate-800/60">
|
<TableRow className="border-b border-slate-200 dark:border-slate-800/60 [&_th]:h-10 [&_th]:text-xs [&_th]:font-medium [&_th]:uppercase [&_th]:tracking-wide [&_th]:text-muted-foreground [&_th:first-child]:rounded-tl-lg [&_th:last-child]:rounded-tr-lg">
|
||||||
<TableHead className="w-[30%] min-w-[220px] pl-6 text-slate-500 dark:text-slate-300">Empresa</TableHead>
|
<TableHead className="w-[30%] min-w-[220px] pl-6">Empresa</TableHead>
|
||||||
<TableHead className="w-[22%] min-w-[180px] pl-4 text-slate-500 dark:text-slate-300">Provisionamento</TableHead>
|
<TableHead className="w-[22%] min-w-[180px] pl-4">Provisionamento</TableHead>
|
||||||
<TableHead className="w-[18%] min-w-[160px] pl-12 text-slate-500 dark:text-slate-300">Cliente avulso</TableHead>
|
<TableHead className="w-[18%] min-w-[160px] pl-12">Cliente avulso</TableHead>
|
||||||
<TableHead className="w-[20%] min-w-[170px] pl-12 text-slate-500 dark:text-slate-300">Uso e alertas</TableHead>
|
<TableHead className="w-[20%] min-w-[170px] pl-12">Uso e alertas</TableHead>
|
||||||
<TableHead className="w-[10%] min-w-[90px] pr-6 text-right text-slate-500 dark:text-slate-300">Ações</TableHead>
|
<TableHead className="w-[10%] min-w-[90px] pr-6 text-right">Ações</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -683,6 +695,8 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
? formatDistanceToNow(alertInfo.createdAt, { addSuffix: true, locale: ptBR })
|
? formatDistanceToNow(alertInfo.createdAt, { addSuffix: true, locale: ptBR })
|
||||||
: null
|
: null
|
||||||
const formattedPhone = formatPhoneDisplay(company.phone)
|
const formattedPhone = formatPhoneDisplay(company.phone)
|
||||||
|
const companyMachines = machinesByCompanyId.get(company.id) ?? []
|
||||||
|
const machineCount = companyMachines.length
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={company.id}
|
key={company.id}
|
||||||
|
|
@ -710,6 +724,12 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
{company.contractedHoursPerMonth}h/mês
|
{company.contractedHoursPerMonth}h/mês
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-200 bg-white text-[11px] font-medium dark:border-slate-800 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
{machineCount} máquina{machineCount === 1 ? "" : "s"}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span className="text-[12px] font-medium text-slate-500 dark:text-slate-400">
|
<span className="text-[12px] font-medium text-slate-500 dark:text-slate-400">
|
||||||
|
|
@ -803,6 +823,16 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="pr-6 text-right align-top">
|
<TableCell className="pr-6 text-right align-top">
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
|
||||||
|
onClick={() => setMachinesDialog({ companyId: company.id, name: company.name })}
|
||||||
|
>
|
||||||
|
<IconDeviceDesktop className="size-4" /> Ver máquinas
|
||||||
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="icon" className="ml-auto">
|
<Button variant="outline" size="icon" className="ml-auto">
|
||||||
|
|
@ -829,6 +859,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
|
|
@ -845,6 +876,54 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<Dialog open={!!machinesDialog} onOpenChange={(open) => { if (!open) setMachinesDialog(null) }}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Máquinas — {machinesDialog?.name ?? ""}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{machinesDialogList.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Nenhuma máquina vinculada a esta empresa.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{machinesDialogList.map((machine) => {
|
||||||
|
const statusKey = machine.isActive === false ? "deactivated" : machine.status
|
||||||
|
const statusVariant = getMachineStatusVariant(statusKey)
|
||||||
|
return (
|
||||||
|
<li key={machine.id} className="flex flex-col gap-2 rounded-xl border border-slate-200 bg-slate-50/60 px-4 py-3 text-sm text-neutral-700">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-neutral-900">{machine.hostname}</p>
|
||||||
|
<p className="text-xs text-neutral-500">{machine.authEmail ?? "Sem e-mail definido"}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className={cn("h-7 px-3 text-xs font-medium", statusVariant.className)}>
|
||||||
|
{statusVariant.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
|
||||||
|
<span>{machine.osName ?? "SO desconhecido"}</span>
|
||||||
|
{machine.osVersion ? <span className="text-neutral-400">•</span> : null}
|
||||||
|
{machine.osVersion ? <span>{machine.osVersion}</span> : null}
|
||||||
|
{machine.architecture ? (
|
||||||
|
<span className="rounded-full bg-white px-2 py-0.5 text-[11px] font-medium text-neutral-600 shadow-sm">
|
||||||
|
{machine.architecture.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button asChild variant="outline" size="sm" className="text-xs">
|
||||||
|
<Link href={`/admin/machines/${machine.id}`}>Ver detalhes</Link>
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
Último sinal: {formatRelativeTimestamp(machine.lastHeartbeatAt) ?? "nunca"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Card>
|
</Card>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
|
|
@ -892,6 +971,7 @@ const MACHINE_STATUS_VARIANTS: Record<string, { label: string; className: string
|
||||||
stale: { label: "Sem sinal", className: "border-slate-300 bg-slate-200/60 text-slate-700" },
|
stale: { label: "Sem sinal", className: "border-slate-300 bg-slate-200/60 text-slate-700" },
|
||||||
maintenance: { label: "Manutenção", className: "border-amber-200 bg-amber-500/10 text-amber-600" },
|
maintenance: { label: "Manutenção", className: "border-amber-200 bg-amber-500/10 text-amber-600" },
|
||||||
blocked: { label: "Bloqueada", className: "border-orange-200 bg-orange-500/10 text-orange-600" },
|
blocked: { label: "Bloqueada", className: "border-orange-200 bg-orange-500/10 text-orange-600" },
|
||||||
|
deactivated: { label: "Desativada", className: "border-slate-300 bg-slate-100 text-slate-600" },
|
||||||
unknown: { label: "Desconhecida", className: "border-slate-200 bg-slate-100 text-slate-600" },
|
unknown: { label: "Desconhecida", className: "border-slate-200 bg-slate-100 text-slate-600" },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -902,7 +982,7 @@ function getMachineStatusVariant(status?: string | null) {
|
||||||
|
|
||||||
function summarizeStatus(machines: MachineSummary[]): Record<string, number> {
|
function summarizeStatus(machines: MachineSummary[]): Record<string, number> {
|
||||||
return machines.reduce<Record<string, number>>((acc, machine) => {
|
return machines.reduce<Record<string, number>>((acc, machine) => {
|
||||||
const normalized = (machine.status ?? "unknown").toLowerCase()
|
const normalized = (machine.isActive === false ? "deactivated" : machine.status ?? "unknown").toLowerCase()
|
||||||
acc[normalized] = (acc[normalized] ?? 0) + 1
|
acc[normalized] = (acc[normalized] ?? 0) + 1
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
"use client"
|
"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 { useQuery } from "convex/react"
|
||||||
import { format, formatDistanceToNowStrict } from "date-fns"
|
import { format, formatDistanceToNowStrict } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { toast } from "sonner"
|
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 { api } from "@/convex/_generated/api"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
@ -24,7 +25,9 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { ChartContainer } from "@/components/ui/chart"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { RadialBarChart, RadialBar, PolarAngleAxis } from "recharts"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
|
@ -567,6 +570,7 @@ export type MachinesQueryItem = {
|
||||||
assignedUserName: string | null
|
assignedUserName: string | null
|
||||||
assignedUserRole: string | null
|
assignedUserRole: string | null
|
||||||
status: string | null
|
status: string | null
|
||||||
|
isActive: boolean
|
||||||
lastHeartbeatAt: number | null
|
lastHeartbeatAt: number | null
|
||||||
heartbeatAgeMs: number | null
|
heartbeatAgeMs: number | null
|
||||||
registeredBy: string | null
|
registeredBy: string | null
|
||||||
|
|
@ -611,6 +615,7 @@ const statusLabels: Record<string, string> = {
|
||||||
stale: "Sem sinal",
|
stale: "Sem sinal",
|
||||||
maintenance: "Manutenção",
|
maintenance: "Manutenção",
|
||||||
blocked: "Bloqueada",
|
blocked: "Bloqueada",
|
||||||
|
deactivated: "Desativada",
|
||||||
unknown: "Desconhecida",
|
unknown: "Desconhecida",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -620,6 +625,7 @@ const statusClasses: Record<string, string> = {
|
||||||
stale: "border-slate-400/30 bg-slate-200 text-slate-700",
|
stale: "border-slate-400/30 bg-slate-200 text-slate-700",
|
||||||
maintenance: "border-amber-500/20 bg-amber-500/15 text-amber-600",
|
maintenance: "border-amber-500/20 bg-amber-500/15 text-amber-600",
|
||||||
blocked: "border-orange-500/20 bg-orange-500/15 text-orange-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",
|
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()
|
const manualStatus = (machine.status ?? "").toLowerCase()
|
||||||
if (["maintenance", "blocked"].includes(manualStatus)) {
|
if (["maintenance", "blocked"].includes(manualStatus)) {
|
||||||
return manualStatus
|
return manualStatus
|
||||||
|
|
@ -871,6 +878,8 @@ function MachineStatusBadge({ status }: { status?: string | null }) {
|
||||||
? "bg-amber-500"
|
? "bg-amber-500"
|
||||||
: s === "blocked"
|
: s === "blocked"
|
||||||
? "bg-orange-500"
|
? "bg-orange-500"
|
||||||
|
: s === "deactivated"
|
||||||
|
? "bg-slate-500"
|
||||||
: "bg-slate-400"
|
: "bg-slate-400"
|
||||||
const ringClass =
|
const ringClass =
|
||||||
s === "online"
|
s === "online"
|
||||||
|
|
@ -881,6 +890,8 @@ function MachineStatusBadge({ status }: { status?: string | null }) {
|
||||||
? "bg-amber-400/30"
|
? "bg-amber-400/30"
|
||||||
: s === "blocked"
|
: s === "blocked"
|
||||||
? "bg-orange-400/30"
|
? "bg-orange-400/30"
|
||||||
|
: s === "deactivated"
|
||||||
|
? "bg-slate-400/40"
|
||||||
: "bg-slate-300/30"
|
: "bg-slate-300/30"
|
||||||
|
|
||||||
const isOnline = s === "online"
|
const isOnline = s === "online"
|
||||||
|
|
@ -961,6 +972,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
? windowsBaseboardRaw
|
? windowsBaseboardRaw
|
||||||
: null
|
: null
|
||||||
const windowsSerialNumber = windowsBaseboard ? readString(toRecord(windowsBaseboard) ?? {}, "SerialNumber", "serialNumber") : undefined
|
const windowsSerialNumber = windowsBaseboard ? readString(toRecord(windowsBaseboard) ?? {}, "SerialNumber", "serialNumber") : undefined
|
||||||
|
const isActive = machine?.isActive ?? true
|
||||||
const windowsMemoryModules = useMemo(() => {
|
const windowsMemoryModules = useMemo(() => {
|
||||||
if (Array.isArray(windowsMemoryModulesRaw)) return windowsMemoryModulesRaw
|
if (Array.isArray(windowsMemoryModulesRaw)) return windowsMemoryModulesRaw
|
||||||
if (windowsMemoryModulesRaw && typeof windowsMemoryModulesRaw === "object") 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 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: <OsIcon osName={machine?.osName} />,
|
||||||
|
})
|
||||||
|
if (machine?.architecture) {
|
||||||
|
chips.push({
|
||||||
|
key: "arch",
|
||||||
|
label: "Arquitetura",
|
||||||
|
value: machine.architecture.toUpperCase(),
|
||||||
|
icon: <Cpu className="size-4 text-neutral-500" />,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (windowsBuildLabel) {
|
||||||
|
chips.push({
|
||||||
|
key: "build",
|
||||||
|
label: "Build",
|
||||||
|
value: windowsBuildLabel,
|
||||||
|
icon: <ServerCog className="size-4 text-neutral-500" />,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (windowsActivationStatus !== null && windowsActivationStatus !== undefined) {
|
||||||
|
chips.push({
|
||||||
|
key: "activation",
|
||||||
|
label: "Licença",
|
||||||
|
value: windowsActivationStatus ? "Ativada" : "Não ativada",
|
||||||
|
icon: windowsActivationStatus ? <ShieldCheck className="size-4 text-emerald-500" /> : <ShieldAlert className="size-4 text-amber-500" />,
|
||||||
|
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: <MemoryStick className="size-4 text-neutral-500" />,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (collaborator?.email) {
|
||||||
|
const collaboratorValue = collaborator.name ? `${collaborator.name} · ${collaborator.email}` : collaborator.email
|
||||||
|
chips.push({
|
||||||
|
key: "collaborator",
|
||||||
|
label: personaLabel,
|
||||||
|
value: collaboratorValue,
|
||||||
|
icon: <ShieldCheck className="size-4 text-neutral-500" />,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return chips
|
||||||
|
}, [machine?.osName, machine?.osVersion, machine?.architecture, windowsVersionLabel, windowsBuildLabel, windowsActivationStatus, primaryGpu, collaborator?.email, collaborator?.name, personaLabel])
|
||||||
|
|
||||||
const companyName = (() => {
|
const companyName = (() => {
|
||||||
if (!companies || !machine?.companySlug) return machine?.companySlug ?? null
|
if (!companies || !machine?.companySlug) return machine?.companySlug ?? null
|
||||||
const found = companies.find((c) => c.slug === machine.companySlug)
|
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"
|
(machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator"
|
||||||
)
|
)
|
||||||
const [savingAccess, setSavingAccess] = useState(false)
|
const [savingAccess, setSavingAccess] = useState(false)
|
||||||
|
const [togglingActive, setTogglingActive] = useState(false)
|
||||||
const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false)
|
const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false)
|
||||||
const jsonText = useMemo(() => {
|
const jsonText = useMemo(() => {
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|
@ -1145,6 +1213,20 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
}
|
}
|
||||||
return JSON.stringify(payload, null, 2)
|
return JSON.stringify(payload, null, 2)
|
||||||
}, [machine, metrics, metadata])
|
}, [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(() => {
|
const filteredJsonHtml = useMemo(() => {
|
||||||
if (!dialogQuery.trim()) return jsonText
|
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 (
|
return (
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -1224,55 +1329,26 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{machine.authEmail ?? "E-mail não definido"}
|
{machine.authEmail ?? "E-mail não definido"}
|
||||||
</p>
|
</p>
|
||||||
{machine.companySlug ? (
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="flex flex-col items-end gap-2 text-sm">
|
||||||
Empresa vinculada: <span className="font-medium text-foreground">{companyName ?? machine.companySlug}</span>
|
{companyName ? (
|
||||||
</p>
|
<div className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-600 shadow-sm">
|
||||||
|
{companyName}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<MachineStatusBadge status={effectiveStatus} />
|
||||||
|
{!isActive ? (
|
||||||
|
<Badge variant="outline" className="h-7 border-rose-200 bg-rose-50 px-3 text-xs font-semibold uppercase text-rose-700">
|
||||||
|
Máquina desativada
|
||||||
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<MachineStatusBadge status={effectiveStatus} />
|
|
||||||
</div>
|
</div>
|
||||||
{/* ping integrado na badge de status */}
|
{/* ping integrado na badge de status */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
{summaryChips.map((chip) => (
|
||||||
<span className="mr-2 inline-flex items-center"><OsIcon osName={machine.osName} /></span>
|
<InfoChip key={chip.key} label={chip.label} value={chip.value} icon={chip.icon} tone={chip.tone} />
|
||||||
{machine.osName ?? "SO desconhecido"} {machine.osVersion ?? ""}
|
))}
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
|
||||||
{machine.architecture?.toUpperCase() ?? "Arquitetura indefinida"}
|
|
||||||
</Badge>
|
|
||||||
{windowsOsInfo ? (
|
|
||||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
|
||||||
Build: {windowsBuildLabel ?? "—"}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
{windowsOsInfo ? (
|
|
||||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
|
||||||
Ativado: {
|
|
||||||
windowsActivationStatus == null
|
|
||||||
? "—"
|
|
||||||
: windowsActivationStatus
|
|
||||||
? "Sim"
|
|
||||||
: "Não"
|
|
||||||
}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
{primaryGpu?.name ? (
|
|
||||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
|
||||||
GPU: {primaryGpu.name}
|
|
||||||
{typeof primaryGpu.memoryBytes === "number" ? ` · ${formatBytes(primaryGpu.memoryBytes)}` : ""}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
{companyName ? (
|
|
||||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
|
||||||
Empresa: {companyName}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
{collaborator?.email ? (
|
|
||||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
|
||||||
{personaLabel}: {collaborator?.name ? `${collaborator.name} · ` : ""}{collaborator.email}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{machine.authEmail ? (
|
{machine.authEmail ? (
|
||||||
|
|
@ -1285,6 +1361,19 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
<ShieldCheck className="size-4" />
|
<ShieldCheck className="size-4" />
|
||||||
Ajustar acesso
|
Ajustar acesso
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={isActive ? "outline" : "default"}
|
||||||
|
className={cn(
|
||||||
|
"gap-2 border-dashed",
|
||||||
|
!isActive && "bg-emerald-600 text-white hover:bg-emerald-600/90"
|
||||||
|
)}
|
||||||
|
onClick={handleToggleActive}
|
||||||
|
disabled={togglingActive}
|
||||||
|
>
|
||||||
|
{isActive ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
|
||||||
|
{isActive ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
|
||||||
|
</Button>
|
||||||
{machine.registeredBy ? (
|
{machine.registeredBy ? (
|
||||||
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
|
||||||
Registrada via {machine.registeredBy}
|
Registrada via {machine.registeredBy}
|
||||||
|
|
@ -1405,12 +1494,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{metrics && typeof metrics === "object" ? (
|
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold">Métricas recentes</h4>
|
<h4 className="text-sm font-semibold">Métricas recentes</h4>
|
||||||
<MetricsGrid metrics={metrics} />
|
<MetricsGrid metrics={metrics} hardware={hardware} disks={disks} />
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{hardware || network || (labels && labels.length > 0) ? (
|
{hardware || network || (labels && labels.length > 0) ? (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
|
|
@ -2149,7 +2236,23 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
<DialogTitle>Inventário completo — {machine.hostname}</DialogTitle>
|
<DialogTitle>Inventário completo — {machine.hostname}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Input placeholder="Buscar no JSON" value={dialogQuery} onChange={(e) => setDialogQuery(e.target.value)} />
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar no JSON"
|
||||||
|
value={dialogQuery}
|
||||||
|
onChange={(e) => setDialogQuery(e.target.value)}
|
||||||
|
className="sm:flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDownloadInventory}
|
||||||
|
className="inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Download className="size-4" /> Baixar JSON
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="max-h-[60vh] overflow-auto rounded-md border border-slate-200 bg-slate-50/60 p-3 text-xs">
|
<div className="max-h-[60vh] overflow-auto rounded-md border border-slate-200 bg-slate-50/60 p-3 text-xs">
|
||||||
<pre className="whitespace-pre-wrap break-words text-muted-foreground" dangerouslySetInnerHTML={{ __html: filteredJsonHtml
|
<pre className="whitespace-pre-wrap break-words text-muted-foreground" dangerouslySetInnerHTML={{ __html: filteredJsonHtml
|
||||||
.replaceAll("__HIGHLIGHT__", '<mark class="bg-yellow-200 text-foreground">')
|
.replaceAll("__HIGHLIGHT__", '<mark class="bg-yellow-200 text-foreground">')
|
||||||
|
|
@ -2225,7 +2328,7 @@ function MachinesGrid({ machines, companyNameBySlug }: { machines: MachinesQuery
|
||||||
|
|
||||||
function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; companyName?: string | null }) {
|
function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; companyName?: string | null }) {
|
||||||
const effectiveStatus = resolveMachineStatus(machine)
|
const effectiveStatus = resolveMachineStatus(machine)
|
||||||
const { className } = getStatusVariant(effectiveStatus)
|
const isActive = machine.isActive
|
||||||
const lastHeartbeat = machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
|
const lastHeartbeat = machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
|
||||||
type AgentMetrics = {
|
type AgentMetrics = {
|
||||||
memoryUsedBytes?: number
|
memoryUsedBytes?: number
|
||||||
|
|
@ -2264,18 +2367,22 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/admin/machines/${machine.id}`} className="group">
|
<Link href={`/admin/machines/${machine.id}`} className="group">
|
||||||
<Card className="relative h-full overflow-hidden border-slate-200 transition-colors hover:border-slate-300">
|
<Card className={cn("relative h-full overflow-hidden border-slate-200 transition-colors hover:border-slate-300", !isActive && "border-slate-300 bg-slate-50") }>
|
||||||
<div className="absolute right-2 top-2">
|
<div className="absolute right-2 top-2">
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative block size-2 rounded-full",
|
"relative block size-2 rounded-full",
|
||||||
className.includes("emerald")
|
effectiveStatus === "online"
|
||||||
? "bg-emerald-500"
|
? "bg-emerald-500"
|
||||||
: className.includes("rose")
|
: effectiveStatus === "offline"
|
||||||
? "bg-rose-500"
|
? "bg-rose-500"
|
||||||
: className.includes("amber")
|
: effectiveStatus === "maintenance"
|
||||||
? "bg-amber-500"
|
? "bg-amber-500"
|
||||||
|
: effectiveStatus === "blocked"
|
||||||
|
? "bg-orange-500"
|
||||||
|
: effectiveStatus === "deactivated"
|
||||||
|
? "bg-slate-500"
|
||||||
: "bg-slate-400"
|
: "bg-slate-400"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -2288,6 +2395,11 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com
|
||||||
<Monitor className="size-4 text-slate-500" /> {machine.hostname}
|
<Monitor className="size-4 text-slate-500" /> {machine.hostname}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="line-clamp-1 text-xs">{machine.authEmail ?? "—"}</CardDescription>
|
<CardDescription className="line-clamp-1 text-xs">{machine.authEmail ?? "—"}</CardDescription>
|
||||||
|
{!isActive ? (
|
||||||
|
<Badge variant="outline" className="mt-2 w-fit border-rose-200 bg-rose-50 text-[10px] font-semibold uppercase tracking-wide text-rose-700">
|
||||||
|
Desativada
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex grow flex-col gap-3 text-sm">
|
<CardContent className="flex grow flex-col gap-3 text-sm">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|
@ -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 data = (metrics ?? {}) as Record<string, unknown>
|
const toneClasses =
|
||||||
// Compat: aceitar chaves do agente desktop (cpuUsagePercent, memoryUsedBytes, memoryTotalBytes)
|
tone === "warning"
|
||||||
const cpu = (() => {
|
? "border-amber-200 bg-amber-50 text-amber-700"
|
||||||
const v = Number(
|
: tone === "muted"
|
||||||
data.cpuUsage ?? data.cpu ?? data.cpu_percent ?? data.cpuUsagePercent ?? NaN
|
? "border-slate-200 bg-slate-50 text-neutral-600"
|
||||||
|
: "border-slate-200 bg-white text-neutral-800"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-3 rounded-xl border px-3 py-2 shadow-sm", toneClasses)}>
|
||||||
|
{icon ? <span className="text-neutral-500">{icon}</span> : null}
|
||||||
|
<div className="min-w-0 leading-tight">
|
||||||
|
<p className="text-xs uppercase text-neutral-500">{label}</p>
|
||||||
|
<p className="truncate text-sm font-semibold">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
return NaN
|
|
||||||
})()
|
function clampPercent(raw: number): number {
|
||||||
const disk = Number(data.diskUsage ?? data.disk ?? NaN)
|
if (!Number.isFinite(raw)) return 0
|
||||||
const gpuUsage = Number(
|
const normalized = raw > 1 && raw <= 100 ? raw : raw <= 1 ? raw * 100 : raw
|
||||||
data.gpuUsage ?? data.gpu ?? data.gpuUsagePercent ?? data.gpu_percent ?? NaN
|
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<string, unknown>
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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 }> = [
|
const cards = [
|
||||||
{ label: "CPU", value: formatPercent(cpu) },
|
{
|
||||||
{ label: "Memória", value: formatBytes(memory) },
|
key: "cpu",
|
||||||
{ label: "Disco", value: Number.isNaN(disk) ? "—" : formatPercent(disk) },
|
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: <Cpu className="size-4 text-neutral-500" />,
|
||||||
|
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: <MemoryStick className="size-4 text-neutral-500" />,
|
||||||
|
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: <HardDrive className="size-4 text-neutral-500" />,
|
||||||
|
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)) {
|
if (derived.gpuPercent !== null) {
|
||||||
cards.push({ label: "GPU", value: formatPercent(gpuUsage) })
|
cards.push({
|
||||||
|
key: "gpu",
|
||||||
|
label: "GPU",
|
||||||
|
percent: derived.gpuPercent,
|
||||||
|
primaryText: formatPercent(derived.gpuPercent),
|
||||||
|
secondaryText: null,
|
||||||
|
icon: <Monitor className="size-4 text-neutral-500" />,
|
||||||
|
color: "var(--chart-4)",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2 sm:grid-cols-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => {
|
||||||
<div key={card.label} className="rounded-md border border-slate-200 bg-slate-50/60 p-3 text-sm text-muted-foreground">
|
const percentValue = Number.isFinite(card.percent ?? NaN) ? Math.max(0, Math.min(100, card.percent ?? 0)) : 0
|
||||||
<p className="text-xs uppercase text-slate-500">{card.label}</p>
|
const percentLabel = card.percent !== null ? `${Math.round(card.percent)}%` : "—"
|
||||||
<p className="text-sm font-semibold text-foreground">{card.value}</p>
|
return (
|
||||||
|
<div key={card.key} className="flex items-center gap-4 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<div className="relative h-20 w-20">
|
||||||
|
<ChartContainer
|
||||||
|
config={{ usage: { label: card.label, color: card.color } }}
|
||||||
|
className="h-20 w-20 aspect-square"
|
||||||
|
>
|
||||||
|
<RadialBarChart
|
||||||
|
data={[{ name: card.label, value: percentValue }]}
|
||||||
|
innerRadius="55%"
|
||||||
|
outerRadius="100%"
|
||||||
|
startAngle={90}
|
||||||
|
endAngle={-270}
|
||||||
|
>
|
||||||
|
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
|
||||||
|
<RadialBar dataKey="value" cornerRadius={10} fill="var(--color-usage)" background />
|
||||||
|
</RadialBarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-sm font-semibold text-neutral-800">
|
||||||
|
{percentLabel}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-neutral-900">
|
||||||
|
{card.icon}
|
||||||
|
{card.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-neutral-700">{card.primaryText}</div>
|
||||||
|
{card.secondaryText ? (
|
||||||
|
<div className="text-xs text-neutral-500">{card.secondaryText}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,11 @@ import {
|
||||||
Clock4,
|
Clock4,
|
||||||
Timer,
|
Timer,
|
||||||
MonitorCog,
|
MonitorCog,
|
||||||
Layers3,
|
|
||||||
UserPlus,
|
UserPlus,
|
||||||
BellRing,
|
BellRing,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Users,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
@ -86,7 +86,7 @@ const navigation: NavigationGroup[] = [
|
||||||
{ title: "SLA e produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" },
|
{ title: "SLA e produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" },
|
||||||
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
|
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
|
||||||
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, 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: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
|
||||||
{ title: "Times & papéis", url: "/admin/teams", icon: UserCog, 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: "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: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
|
||||||
{ title: "Alertas enviados", url: "/admin/alerts", icon: BellRing, requiredRole: "admin" },
|
{ title: "Alertas enviados", url: "/admin/alerts", icon: BellRing, requiredRole: "admin" },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -86,12 +86,12 @@ export function ChartOpenedResolved() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||||
{data.series.length === 0 ? (
|
{data.series.length === 0 ? (
|
||||||
<div className="flex h-[250px] items-center justify-center rounded-xl border border-dashed border-border/60 text-sm text-muted-foreground">
|
<div className="flex h-[320px] items-center justify-center rounded-xl border border-dashed border-border/60 text-sm text-muted-foreground">
|
||||||
Sem dados suficientes no período selecionado.
|
Sem dados suficientes no período selecionado.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
|
<ChartContainer config={chartConfig} className="aspect-auto h-[320px] w-full">
|
||||||
<LineChart data={data.series} margin={{ left: 12, right: 12 }}>
|
<LineChart data={data.series} margin={{ top: 20, left: 16, right: 16, bottom: 12 }}>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
|
|
@ -104,8 +104,8 @@ export function ChartOpenedResolved() {
|
||||||
cursor={false}
|
cursor={false}
|
||||||
content={<ChartTooltipContent indicator="line" />}
|
content={<ChartTooltipContent indicator="line" />}
|
||||||
/>
|
/>
|
||||||
<Line dataKey="opened" type="natural" stroke="var(--color-opened)" strokeWidth={2} dot={false} />
|
<Line dataKey="opened" type="monotone" stroke="var(--color-opened)" strokeWidth={2} dot={{ r: 2 }} strokeLinecap="round" />
|
||||||
<Line dataKey="resolved" type="natural" stroke="var(--color-resolved)" strokeWidth={2} dot={false} />
|
<Line dataKey="resolved" type="monotone" stroke="var(--color-resolved)" strokeWidth={2} dot={{ r: 2 }} strokeLinecap="round" />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { useAuth } from "@/lib/auth-client"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
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 { Dropzone } from "@/components/ui/dropzone"
|
||||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
@ -60,13 +61,26 @@ type ClientTimelineEntry = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
const { convexUserId, session, isCustomer } = useAuth()
|
const { convexUserId, session, isCustomer, machineContext } = useAuth()
|
||||||
const addComment = useMutation(api.tickets.addComment)
|
const addComment = useMutation(api.tickets.addComment)
|
||||||
const getFileUrl = useAction(api.files.getUrl)
|
const getFileUrl = useAction(api.files.getUrl)
|
||||||
const [comment, setComment] = useState("")
|
const [comment, setComment] = useState("")
|
||||||
const [attachments, setAttachments] = useState<
|
const [attachments, setAttachments] = useState<
|
||||||
Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>
|
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(
|
const ticketRaw = useQuery(
|
||||||
api.tickets.getById,
|
api.tickets.getById,
|
||||||
|
|
@ -225,6 +239,10 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
const updatedAgo = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })
|
const updatedAgo = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })
|
||||||
async function handleSubmit(event: React.FormEvent) {
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
if (machineInactive) {
|
||||||
|
toast.error("Esta máquina está desativada. Reative-a para enviar novas mensagens.")
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!convexUserId || !comment.trim() || !ticket) return
|
if (!convexUserId || !comment.trim() || !ticket) return
|
||||||
const toastId = "portal-add-comment"
|
const toastId = "portal-add-comment"
|
||||||
toast.loading("Enviando comentário...", { id: toastId })
|
toast.loading("Enviando comentário...", { id: toastId })
|
||||||
|
|
@ -303,8 +321,13 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6 px-5 pb-6">
|
<CardContent className="space-y-6 px-5 pb-6">
|
||||||
|
{machineInactive ? (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||||
|
Esta máquina está desativada. Ative-a novamente para enviar novas mensagens.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<label htmlFor="comment" className="text-sm font-medium text-neutral-800">
|
<label htmlFor="comment" className="text-sm font-medium text-neutral-800">
|
||||||
Enviar uma mensagem para a equipe
|
Enviar uma mensagem para a equipe
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -313,12 +336,16 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
onChange={(html) => setComment(html)}
|
onChange={(html) => setComment(html)}
|
||||||
placeholder="Descreva o que aconteceu, envie atualizações ou compartilhe novas informações."
|
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"
|
className="rounded-2xl border border-slate-200 shadow-sm focus-within:border-neutral-900 focus-within:ring-neutral-900/20"
|
||||||
|
disabled={machineInactive}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onUploaded={(files) => setAttachments((prev) => [...prev, ...files])}
|
onUploaded={(files) => 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"
|
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 ? (
|
{attachments.length > 0 ? (
|
||||||
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
||||||
|
|
@ -371,10 +398,9 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="text-xs text-neutral-500">Máximo 5MB • Até 5 arquivos</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<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">
|
<Button type="submit" disabled={machineInactive} className="rounded-full bg-neutral-900 px-6 text-sm font-semibold text-white hover:bg-neutral-900/90 disabled:opacity-60">
|
||||||
Enviar comentário
|
Enviar comentário
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -429,6 +455,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
attachment={attachment}
|
attachment={attachment}
|
||||||
getFileUrl={getFileUrl}
|
getFileUrl={getFileUrl}
|
||||||
|
onOpenPreview={(payload) => setPreviewAttachment(payload)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -465,6 +492,38 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
<Dialog open={!!previewAttachment} onOpenChange={(open) => { if (!open) setPreviewAttachment(null) }}>
|
||||||
|
<DialogContent className="max-w-3xl border border-slate-200 p-0">
|
||||||
|
<DialogHeader className="flex items-center justify-between gap-3 px-4 py-3">
|
||||||
|
<DialogTitle className="text-base font-semibold text-neutral-800">
|
||||||
|
{previewAttachment?.name ?? "Visualização do anexo"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogClose className="inline-flex size-7 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-600 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400/30">
|
||||||
|
<X className="size-4" />
|
||||||
|
</DialogClose>
|
||||||
|
</DialogHeader>
|
||||||
|
{previewAttachment ? (
|
||||||
|
isPreviewImage ? (
|
||||||
|
<div className="rounded-b-2xl bg-neutral-900/5">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={previewAttachment.url} alt={previewAttachment.name ?? "Anexo"} className="h-auto w-full rounded-b-2xl" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 px-6 pb-6 text-sm text-neutral-700">
|
||||||
|
<p>Não é possível visualizar este tipo de arquivo aqui. Abra em uma nova aba para conferi-lo.</p>
|
||||||
|
<a
|
||||||
|
href={previewAttachment.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-neutral-800 transition hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
Abrir em nova aba
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -488,7 +547,15 @@ function DetailItem({ label, value, subtitle }: DetailItemProps) {
|
||||||
type CommentAttachment = TicketWithDetails["comments"][number]["attachments"][number]
|
type CommentAttachment = TicketWithDetails["comments"][number]["attachments"][number]
|
||||||
type GetFileUrlAction = (args: { storageId: Id<"_storage"> }) => Promise<string | null>
|
type GetFileUrlAction = (args: { storageId: Id<"_storage"> }) => Promise<string | null>
|
||||||
|
|
||||||
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<string | null>(attachment.url ?? null)
|
const [url, setUrl] = useState<string | null>(attachment.url ?? null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [errored, setErrored] = useState(false)
|
const [errored, setErrored] = useState(false)
|
||||||
|
|
@ -532,10 +599,13 @@ function PortalCommentAttachmentCard({ attachment, getFileUrl }: { attachment: C
|
||||||
|
|
||||||
const handlePreview = useCallback(async () => {
|
const handlePreview = useCallback(async () => {
|
||||||
const target = await ensureUrl()
|
const target = await ensureUrl()
|
||||||
if (target) {
|
if (!target) return
|
||||||
window.open(target, "_blank", "noopener,noreferrer")
|
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 handleDownload = useCallback(async () => {
|
||||||
const target = await ensureUrl()
|
const target = await ensureUrl()
|
||||||
|
|
|
||||||
|
|
@ -41,12 +41,16 @@ export function PortalTicketList() {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
<CardHeader className="flex items-center gap-2 px-5 py-5">
|
<CardContent className="flex h-56 flex-col items-center justify-center gap-3 px-5 text-center">
|
||||||
<Spinner className="size-4 text-neutral-500" />
|
<div className="inline-flex size-12 items-center justify-center rounded-full border border-slate-200 bg-slate-50">
|
||||||
|
<Spinner className="size-5 text-neutral-600" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
<CardTitle className="text-lg font-semibold text-neutral-900">Carregando chamados...</CardTitle>
|
<CardTitle className="text-lg font-semibold text-neutral-900">Carregando chamados...</CardTitle>
|
||||||
</CardHeader>
|
<p className="text-sm text-neutral-600">
|
||||||
<CardContent className="px-5 pb-6 text-sm text-neutral-600">
|
|
||||||
Estamos buscando seus chamados mais recentes. Isso deve levar apenas alguns instantes.
|
Estamos buscando seus chamados mais recentes. Isso deve levar apenas alguns instantes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
function formatHours(ms: number) {
|
|
||||||
const hours = ms / 3600000
|
|
||||||
return hours.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
type HoursItem = {
|
type HoursItem = {
|
||||||
companyId: string
|
companyId: string
|
||||||
|
|
@ -52,16 +48,58 @@ export function HoursReport() {
|
||||||
return list
|
return list
|
||||||
}, [data?.items, query, companyId])
|
}, [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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Horas por cliente</CardTitle>
|
<CardTitle>Horas</CardTitle>
|
||||||
<CardDescription>Horas internas e externas registradas por empresa.</CardDescription>
|
<CardDescription>Visualize o esforço interno e externo por empresa e acompanhe o consumo contratado.</CardDescription>
|
||||||
<CardAction>
|
<CardAction>
|
||||||
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-end">
|
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Pesquisar cliente..."
|
placeholder="Pesquisar empresa..."
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
className="h-9 w-full min-w-56 sm:w-72"
|
className="h-9 w-full min-w-56 sm:w-72"
|
||||||
|
|
@ -83,7 +121,10 @@ export function HoursReport() {
|
||||||
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
|
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
<Button asChild size="sm" variant="outline">
|
<Button asChild size="sm" variant="outline">
|
||||||
<a href={`/api/reports/hours-by-client.csv?range=${timeRange}${query ? `&q=${encodeURIComponent(query)}` : ""}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
|
<a
|
||||||
|
href={`/api/reports/hours-by-client.csv?range=${timeRange}${query ? `&q=${encodeURIComponent(query)}` : ""}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
|
||||||
|
download
|
||||||
|
>
|
||||||
Exportar CSV
|
Exportar CSV
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -91,48 +132,80 @@ export function HoursReport() {
|
||||||
</CardAction>
|
</CardAction>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<table className="min-w-full table-fixed text-sm">
|
{[
|
||||||
<thead>
|
{ key: "internal", label: "Horas internas", value: numberFormatter.format(totals.internal) },
|
||||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
{ key: "external", label: "Horas externas", value: numberFormatter.format(totals.external) },
|
||||||
<th className="py-2 pr-4">Cliente</th>
|
{ key: "total", label: "Total acumulado", value: numberFormatter.format(totals.total) },
|
||||||
<th className="py-2 pr-4">Avulso</th>
|
].map((item) => (
|
||||||
<th className="py-2 pr-4">Horas internas</th>
|
<div key={item.key} className="rounded-xl border border-slate-200 bg-slate-50/80 p-4 shadow-sm">
|
||||||
<th className="py-2 pr-4">Horas externas</th>
|
<p className="text-xs uppercase tracking-wide text-neutral-500">{item.label}</p>
|
||||||
<th className="py-2 pr-4">Total</th>
|
<p className="mt-2 text-2xl font-semibold text-neutral-900">{item.value} h</p>
|
||||||
<th className="py-2 pr-4">Contratadas/mês</th>
|
|
||||||
<th className="py-2 pr-4">Uso</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-200">
|
|
||||||
{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 (
|
|
||||||
<tr key={row.companyId}>
|
|
||||||
<td className="py-2 pr-4 font-medium text-neutral-900">{row.name}</td>
|
|
||||||
<td className="py-2 pr-4">{row.isAvulso ? "Sim" : "Não"}</td>
|
|
||||||
<td className="py-2 pr-4">{formatHours(row.internalMs)}</td>
|
|
||||||
<td className="py-2 pr-4">{formatHours(row.externalMs)}</td>
|
|
||||||
<td className="py-2 pr-4 font-semibold text-neutral-900">{formatHours(row.totalMs)}</td>
|
|
||||||
<td className="py-2 pr-4">{contracted ?? "—"}</td>
|
|
||||||
<td className="py-2 pr-4">
|
|
||||||
{pct !== null ? (
|
|
||||||
<Badge variant={pctBadgeVariant} className="rounded-full px-3 py-1 text-[11px] uppercase tracking-wide">
|
|
||||||
{pct}%
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<span className="text-neutral-500">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredWithComputed.length === 0 ? (
|
||||||
|
<div className="mt-6 rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-8 text-center text-sm text-muted-foreground">
|
||||||
|
Nenhuma empresa encontrada para o filtro selecionado.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||||
|
{filteredWithComputed.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.companyId}
|
||||||
|
className="flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-slate-300"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-neutral-900">{row.name}</h3>
|
||||||
|
<p className="text-xs text-neutral-500">ID {row.companyId}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={row.isAvulso ? "secondary" : "outline"} className="rounded-full px-3 py-1 text-xs font-medium">
|
||||||
|
{row.isAvulso ? "Cliente avulso" : "Recorrente"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-3 rounded-xl border border-slate-100 bg-slate-50/70 p-4 text-sm text-neutral-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs uppercase text-neutral-500">Horas internas</span>
|
||||||
|
<span className="font-semibold text-neutral-900">{numberFormatter.format(row.internal)} h</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs uppercase text-neutral-500">Horas externas</span>
|
||||||
|
<span className="font-semibold text-neutral-900">{numberFormatter.format(row.external)} h</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs uppercase text-neutral-500">Total</span>
|
||||||
|
<span className="font-semibold text-neutral-900">{numberFormatter.format(row.total)} h</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs text-neutral-500">
|
||||||
|
<span>Contratadas/mês</span>
|
||||||
|
<span className="font-medium text-neutral-800">
|
||||||
|
{row.contracted ? `${numberFormatter.format(row.contracted)} h` : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs text-neutral-500">
|
||||||
|
<span>Uso</span>
|
||||||
|
<span className="font-semibold text-neutral-800">
|
||||||
|
{row.usagePercent !== null ? `${row.usagePercent}%` : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{row.usagePercent !== null ? (
|
||||||
|
<Progress value={row.usagePercent} className="h-2" />
|
||||||
|
) : (
|
||||||
|
<div className="rounded-full border border-dashed border-slate-200 py-1 text-center text-[11px] text-neutral-500">
|
||||||
|
Defina horas contratadas para acompanhar o uso
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { useQuery } from "convex/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 { api } from "@/convex/_generated/api"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -27,11 +28,6 @@ function formatMinutes(value: number | null) {
|
||||||
return `${hours}h ${minutes}min`
|
return `${hours}h ${minutes}min`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatScore(value: number | null) {
|
|
||||||
if (value === null) return "—"
|
|
||||||
return value.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SectionCards() {
|
export function SectionCards() {
|
||||||
const { session, convexUserId, isStaff } = useAuth()
|
const { session, convexUserId, isStaff } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
@ -65,6 +61,29 @@ export function SectionCards() {
|
||||||
|
|
||||||
const TrendIcon = trendInfo.icon
|
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 (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 px-4 sm:grid-cols-2 xl:grid-cols-4 xl:px-8">
|
<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">
|
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
|
||||||
|
|
@ -150,20 +169,30 @@ export function SectionCards() {
|
||||||
|
|
||||||
<Card className="@container/card border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 p-5 shadow-sm">
|
<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">
|
<CardHeader className="gap-3">
|
||||||
<CardDescription>CSAT recente</CardDescription>
|
<CardDescription>Tickets resolvidos (7 dias)</CardDescription>
|
||||||
<CardTitle className="text-3xl font-semibold tabular-nums">
|
<CardTitle className="text-3xl font-semibold tabular-nums">
|
||||||
{dashboard ? formatScore(dashboard.csat.averageScore) : <Skeleton className="h-8 w-12" />}
|
{dashboard ? dashboard.resolution.resolvedLast7d : <Skeleton className="h-8 w-12" />}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardAction>
|
<CardAction>
|
||||||
<Badge variant="outline" className="rounded-full gap-1 px-2 py-1 text-xs">
|
<Badge
|
||||||
<IconMessages className="size-3.5" />
|
variant="outline"
|
||||||
{dashboard ? `${dashboard.csat.totalSurveys} pesquisas` : "—"}
|
className={cn(
|
||||||
|
"rounded-full gap-1 px-2 py-1 text-xs",
|
||||||
|
resolutionInfo.positive ? "text-emerald-600" : "text-amber-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{resolutionInfo.positive ? (
|
||||||
|
<IconTrendingUp className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<IconTrendingDown className="size-3.5" />
|
||||||
|
)}
|
||||||
|
{resolutionInfo.badgeLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardAction>
|
</CardAction>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter className="flex-col items-start gap-1 text-sm text-muted-foreground">
|
<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 className="text-foreground">{resolutionInfo.rateLabel}</span>
|
||||||
<span>Escala de 1 a 5 pontos.</span>
|
<span>Comparação com os 7 dias anteriores.</span>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface SiteHeaderProps {
|
||||||
lead?: string
|
lead?: string
|
||||||
primaryAction?: ReactNode
|
primaryAction?: ReactNode
|
||||||
secondaryAction?: ReactNode
|
secondaryAction?: ReactNode
|
||||||
|
primaryAlignment?: "right" | "center"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SiteHeader({
|
export function SiteHeader({
|
||||||
|
|
@ -17,7 +18,13 @@ export function SiteHeader({
|
||||||
lead,
|
lead,
|
||||||
primaryAction,
|
primaryAction,
|
||||||
secondaryAction,
|
secondaryAction,
|
||||||
|
primaryAlignment = "right",
|
||||||
}: SiteHeaderProps) {
|
}: 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 (
|
return (
|
||||||
<header className="flex h-auto shrink-0 flex-wrap items-start gap-3 border-b bg-background/80 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60 transition-[width,height] ease-linear sm:h-(--header-height) sm:flex-nowrap sm:items-center sm:px-6 lg:px-8 sm:group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
<header className="flex h-auto shrink-0 flex-wrap items-start gap-3 border-b bg-background/80 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60 transition-[width,height] ease-linear sm:h-(--header-height) sm:flex-nowrap sm:items-center sm:px-6 lg:px-8 sm:group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
|
|
@ -26,7 +33,7 @@ export function SiteHeader({
|
||||||
{lead ? <span className="text-sm text-muted-foreground">{lead}</span> : null}
|
{lead ? <span className="text-sm text-muted-foreground">{lead}</span> : null}
|
||||||
<h1 className="text-lg font-semibold">{title}</h1>
|
<h1 className="text-lg font-semibold">{title}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col items-stretch gap-2 sm:w-auto sm:flex-row sm:items-center">
|
<div className={actionsClassName}>
|
||||||
{secondaryAction}
|
{secondaryAction}
|
||||||
{primaryAction}
|
{primaryAction}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
import { NewTicketDialog } from "./new-ticket-dialog"
|
import { NewTicketDialog } from "./new-ticket-dialog"
|
||||||
|
|
||||||
export function NewTicketDialogDeferred() {
|
export function NewTicketDialogDeferred({ triggerClassName }: { triggerClassName?: string } = {}) {
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -17,7 +18,10 @@ export function NewTicketDialogDeferred() {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
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"
|
className={cn(
|
||||||
|
"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",
|
||||||
|
triggerClassName
|
||||||
|
)}
|
||||||
disabled
|
disabled
|
||||||
aria-disabled
|
aria-disabled
|
||||||
>
|
>
|
||||||
|
|
@ -26,5 +30,5 @@ export function NewTicketDialogDeferred() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <NewTicketDialog />
|
return <NewTicketDialog triggerClassName={triggerClassName} />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
} from "@/components/tickets/priority-select"
|
} from "@/components/tickets/priority-select"
|
||||||
import { CategorySelectFields } from "@/components/tickets/category-select"
|
import { CategorySelectFields } from "@/components/tickets/category-select"
|
||||||
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
subject: z.string().default(""),
|
subject: z.string().default(""),
|
||||||
|
|
@ -38,7 +39,7 @@ const schema = z.object({
|
||||||
subcategoryId: z.string().min(1, "Selecione uma categoria secundária"),
|
subcategoryId: z.string().min(1, "Selecione uma categoria secundária"),
|
||||||
})
|
})
|
||||||
|
|
||||||
export function NewTicketDialog() {
|
export function NewTicketDialog({ triggerClassName }: { triggerClassName?: string } = {}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const form = useForm<z.infer<typeof schema>>({
|
const form = useForm<z.infer<typeof schema>>({
|
||||||
|
|
@ -76,6 +77,10 @@ export function NewTicketDialog() {
|
||||||
[staffRaw]
|
[staffRaw]
|
||||||
)
|
)
|
||||||
const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
|
const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
|
||||||
|
const attachmentsTotalBytes = useMemo(
|
||||||
|
() => attachments.reduce((acc, item) => acc + (item.size ?? 0), 0),
|
||||||
|
[attachments]
|
||||||
|
)
|
||||||
const priorityValue = form.watch("priority") as TicketPriority
|
const priorityValue = form.watch("priority") as TicketPriority
|
||||||
const channelValue = form.watch("channel")
|
const channelValue = form.watch("channel")
|
||||||
const queueValue = form.watch("queueName") ?? "NONE"
|
const queueValue = form.watch("queueName") ?? "NONE"
|
||||||
|
|
@ -200,7 +205,10 @@ export function NewTicketDialog() {
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
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"
|
className={cn(
|
||||||
|
"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",
|
||||||
|
triggerClassName
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Novo ticket
|
Novo ticket
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -279,6 +287,8 @@ export function NewTicketDialog() {
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onUploaded={(files) => setAttachments((prev) => [...prev, ...files])}
|
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"
|
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}
|
||||||
/>
|
/>
|
||||||
<FieldError className="mt-1">Formatos comuns de imagem e documentos são aceitos.</FieldError>
|
<FieldError className="mt-1">Formatos comuns de imagem e documentos são aceitos.</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ function TicketRow({ ticket, entering }: { ticket: Ticket; entering: boolean })
|
||||||
<span className="text-xl font-bold text-neutral-900">#{ticket.reference}</span>
|
<span className="text-xl font-bold text-neutral-900">#{ticket.reference}</span>
|
||||||
<span className="truncate text-neutral-500">{queueLabel}</span>
|
<span className="truncate text-neutral-500">{queueLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-right">
|
<div className="ml-auto flex items-start gap-2 text-right">
|
||||||
<TicketStatusBadge status={ticket.status} className="h-8 px-3.5 text-sm" />
|
<TicketStatusBadge status={ticket.status} className="h-8 px-3.5 text-sm" />
|
||||||
<TicketPriorityPill priority={ticket.priority} className="h-8 px-3.5 text-sm" />
|
<TicketPriorityPill priority={ticket.priority} className="h-8 px-3.5 text-sm" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
const updateComment = useMutation(api.tickets.updateComment)
|
const updateComment = useMutation(api.tickets.updateComment)
|
||||||
const [body, setBody] = useState("")
|
const [body, setBody] = useState("")
|
||||||
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
|
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
|
||||||
|
const attachmentsToSendTotalBytes = useMemo(
|
||||||
|
() => attachmentsToSend.reduce((acc, item) => acc + (item.size ?? 0), 0),
|
||||||
|
[attachmentsToSend]
|
||||||
|
)
|
||||||
const [preview, setPreview] = useState<string | null>(null)
|
const [preview, setPreview] = useState<string | null>(null)
|
||||||
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id" | "author" | "visibility" | "body" | "attachments" | "createdAt" | "updatedAt">[]>([])
|
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id" | "author" | "visibility" | "body" | "attachments" | "createdAt" | "updatedAt">[]>([])
|
||||||
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("INTERNAL")
|
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("INTERNAL")
|
||||||
|
|
@ -358,7 +362,11 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
|
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
|
||||||
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
|
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
|
||||||
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
|
<Dropzone
|
||||||
|
onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])}
|
||||||
|
currentFileCount={attachmentsToSend.length}
|
||||||
|
currentTotalBytes={attachmentsToSendTotalBytes}
|
||||||
|
/>
|
||||||
{attachmentsToSend.length > 0 ? (
|
{attachmentsToSend.length > 0 ? (
|
||||||
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
||||||
{attachmentsToSend.map((attachment, index) => {
|
{attachmentsToSend.map((attachment, index) => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useAction } from "convex/react";
|
import { useAction } from "convex/react";
|
||||||
import { api } from "@/convex/_generated/api";
|
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 { cn } from "@/lib/utils";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Upload, Check, X, AlertCircle } from "lucide-react";
|
import { Upload, Check, X, AlertCircle } from "lucide-react";
|
||||||
|
|
@ -16,12 +16,18 @@ export function Dropzone({
|
||||||
maxSize = 5 * 1024 * 1024,
|
maxSize = 5 * 1024 * 1024,
|
||||||
multiple = true,
|
multiple = true,
|
||||||
className,
|
className,
|
||||||
|
currentFileCount = 0,
|
||||||
|
currentTotalBytes = 0,
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
onUploaded?: (files: Uploaded[]) => void;
|
onUploaded?: (files: Uploaded[]) => void;
|
||||||
maxFiles?: number;
|
maxFiles?: number;
|
||||||
maxSize?: number;
|
maxSize?: number;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
currentFileCount?: number;
|
||||||
|
currentTotalBytes?: number;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const generateUrl = useAction(api.files.generateUploadUrl);
|
const generateUrl = useAction(api.files.generateUploadUrl);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -30,8 +36,29 @@ export function Dropzone({
|
||||||
Array<{ id: string; name: string; progress: number; status: "uploading" | "done" | "error" }>
|
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 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[] = [];
|
const uploaded: Uploaded[] = [];
|
||||||
for (const file of list) {
|
for (const file of list) {
|
||||||
if (file.size > maxSize) {
|
if (file.size > maxSize) {
|
||||||
|
|
@ -82,34 +109,59 @@ export function Dropzone({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (uploaded.length) onUploaded?.(uploaded);
|
if (uploaded.length) onUploaded?.(uploaded);
|
||||||
}, [generateUrl, maxFiles, maxSize, onUploaded]);
|
}, [disabled, generateUrl, maxFiles, maxSize, normalizedFileCount, onUploaded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-3", className)}>
|
<div className={cn("space-y-3", className)}>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={disabled ? -1 : 0}
|
||||||
className={cn(
|
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",
|
"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"
|
drag && !disabled ? "border-black bg-black/5" : undefined,
|
||||||
|
disabled
|
||||||
|
? "cursor-not-allowed opacity-60 focus:ring-0"
|
||||||
|
: "hover:border-black hover:bg-black/5"
|
||||||
)}
|
)}
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => {
|
||||||
|
if (disabled) return
|
||||||
|
inputRef.current?.click()
|
||||||
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
|
if (disabled) return
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
inputRef.current?.click()
|
inputRef.current?.click()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragEnter={(e) => { e.preventDefault(); setDrag(true); }}
|
onDragEnter={(e) => {
|
||||||
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
|
if (disabled) return
|
||||||
onDragLeave={(e) => { e.preventDefault(); setDrag(false); }}
|
e.preventDefault()
|
||||||
onDrop={(e) => { e.preventDefault(); setDrag(false); startUpload(e.dataTransfer.files); }}
|
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)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
|
disabled={disabled}
|
||||||
onChange={(e) => e.target.files && startUpload(e.target.files)}
|
onChange={(e) => e.target.files && startUpload(e.target.files)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
|
@ -123,7 +175,7 @@ export function Dropzone({
|
||||||
<p className="text-sm text-neutral-800">
|
<p className="text-sm text-neutral-800">
|
||||||
Arraste arquivos aqui ou <span className="font-semibold text-black underline decoration-dotted">selecione</span>
|
Arraste arquivos aqui ou <span className="font-semibold text-black underline decoration-dotted">selecione</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-neutral-500">Máximo {Math.round(maxSize/1024/1024)}MB • Até {maxFiles} arquivos</p>
|
<p className="text-xs text-neutral-500 text-center">{infoMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
|
|
@ -178,3 +230,16 @@ export function Dropzone({
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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]}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ type MachineContext = {
|
||||||
assignedUserName: string | null
|
assignedUserName: string | null
|
||||||
assignedUserRole: string | null
|
assignedUserRole: string | null
|
||||||
companyId: string | null
|
companyId: string | null
|
||||||
|
isActive: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type MachineContextError = {
|
type MachineContextError = {
|
||||||
|
|
@ -171,6 +172,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
assignedUserName: string | null
|
assignedUserName: string | null
|
||||||
assignedUserRole: string | null
|
assignedUserRole: string | null
|
||||||
companyId: string | null
|
companyId: string | null
|
||||||
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
setMachineContext({
|
setMachineContext({
|
||||||
machineId: machine.id,
|
machineId: machine.id,
|
||||||
|
|
@ -181,6 +183,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
assignedUserName: machine.assignedUserName ?? null,
|
assignedUserName: machine.assignedUserName ?? null,
|
||||||
assignedUserRole: machine.assignedUserRole ?? null,
|
assignedUserRole: machine.assignedUserRole ?? null,
|
||||||
companyId: machine.companyId ?? null,
|
companyId: machine.companyId ?? null,
|
||||||
|
isActive: machine.isActive ?? true,
|
||||||
})
|
})
|
||||||
setMachineContextError(null)
|
setMachineContextError(null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,12 +68,15 @@ type EnsureCollaboratorAccountParams = {
|
||||||
name: string
|
name: string
|
||||||
tenantId: string
|
tenantId: string
|
||||||
companyId?: string | null
|
companyId?: string | null
|
||||||
|
role?: "ADMIN" | "MANAGER" | "AGENT" | "COLLABORATOR"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureCollaboratorAccount(params: EnsureCollaboratorAccountParams) {
|
export async function ensureCollaboratorAccount(params: EnsureCollaboratorAccountParams) {
|
||||||
const normalizedEmail = params.email.trim().toLowerCase()
|
const normalizedEmail = params.email.trim().toLowerCase()
|
||||||
const name = params.name.trim() || normalizedEmail
|
const name = params.name.trim() || normalizedEmail
|
||||||
const tenantId = params.tenantId
|
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 existingAuth = await prisma.authUser.findUnique({ where: { email: normalizedEmail } })
|
||||||
const authUser = existingAuth
|
const authUser = existingAuth
|
||||||
|
|
@ -82,7 +85,7 @@ export async function ensureCollaboratorAccount(params: EnsureCollaboratorAccoun
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
tenantId,
|
tenantId,
|
||||||
role: "collaborator",
|
role: authRole,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: await prisma.authUser.create({
|
: await prisma.authUser.create({
|
||||||
|
|
@ -90,7 +93,7 @@ export async function ensureCollaboratorAccount(params: EnsureCollaboratorAccoun
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
name,
|
name,
|
||||||
tenantId,
|
tenantId,
|
||||||
role: "collaborator",
|
role: authRole,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -117,14 +120,14 @@ export async function ensureCollaboratorAccount(params: EnsureCollaboratorAccoun
|
||||||
update: {
|
update: {
|
||||||
name,
|
name,
|
||||||
tenantId,
|
tenantId,
|
||||||
role: "COLLABORATOR",
|
role: targetRole,
|
||||||
companyId: params.companyId ?? undefined,
|
companyId: params.companyId ?? undefined,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
name,
|
name,
|
||||||
tenantId,
|
tenantId,
|
||||||
role: "COLLABORATOR",
|
role: targetRole,
|
||||||
companyId: params.companyId ?? undefined,
|
companyId: params.companyId ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue