feat: overhaul admin user management and desktop UX
This commit is contained in:
parent
7d6f3bea01
commit
ecad81b0ea
16 changed files with 1546 additions and 395 deletions
|
|
@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|||
import { createRoot } from "react-dom/client"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { Store } from "@tauri-apps/plugin-store"
|
||||
import { ExternalLink, Eye, EyeOff, GalleryVerticalEnd, RefreshCw } from "lucide-react"
|
||||
import { ExternalLink, Eye, EyeOff, GalleryVerticalEnd, Loader2, RefreshCw } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
|
||||
import { cn } from "./lib/utils"
|
||||
|
||||
|
|
@ -54,10 +54,18 @@ type MachineRegisterResponse = {
|
|||
} | null
|
||||
}
|
||||
|
||||
type CompanyOption = {
|
||||
id: string
|
||||
tenantId: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
type AgentConfig = {
|
||||
machineId: string
|
||||
tenantId?: string | null
|
||||
companySlug?: string | null
|
||||
companyName?: string | null
|
||||
machineEmail?: string | null
|
||||
collaboratorEmail?: string | null
|
||||
collaboratorName?: string | null
|
||||
|
|
@ -83,6 +91,7 @@ declare global {
|
|||
|
||||
const STORE_FILENAME = "machine-agent.json"
|
||||
const DEFAULT_APP_URL = import.meta.env.MODE === "production" ? "https://tickets.esdrasrenan.com.br" : "http://localhost:3000"
|
||||
const DEFAULT_TENANT_ID = "tenant-atlas"
|
||||
|
||||
function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) {
|
||||
const trimmed = (value ?? fallback).trim()
|
||||
|
|
@ -134,6 +143,7 @@ function App() {
|
|||
const [busy, setBusy] = useState(false)
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [showSecret, setShowSecret] = useState(false)
|
||||
const [isLaunchingSystem, setIsLaunchingSystem] = useState(false)
|
||||
|
||||
const [provisioningSecret, setProvisioningSecret] = useState("")
|
||||
const [tenantId, setTenantId] = useState("")
|
||||
|
|
@ -141,6 +151,9 @@ function App() {
|
|||
const [collabEmail, setCollabEmail] = useState("")
|
||||
const [collabName, setCollabName] = useState("")
|
||||
const [accessRole, setAccessRole] = useState<"collaborator" | "manager">("collaborator")
|
||||
const [companyOptions, setCompanyOptions] = useState<CompanyOption[]>([])
|
||||
const [selectedCompany, setSelectedCompany] = useState<CompanyOption | null>(null)
|
||||
const [companyLookupPending, setCompanyLookupPending] = useState(false)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [updateInfo, setUpdateInfo] = useState<{ message: string; tone: "info" | "success" | "error" } | null>({
|
||||
message: "Atualizações automáticas são verificadas a cada inicialização.",
|
||||
|
|
@ -161,6 +174,8 @@ function App() {
|
|||
setAccessRole(cfg?.accessRole ?? "collaborator")
|
||||
if (cfg?.collaboratorEmail) setCollabEmail(cfg.collaboratorEmail)
|
||||
if (cfg?.collaboratorName) setCollabName(cfg.collaboratorName)
|
||||
if (cfg?.companyName) setCompany(cfg.companyName)
|
||||
if (cfg?.tenantId) setTenantId(cfg.tenantId)
|
||||
if (!t) {
|
||||
const p = await invoke<MachineProfile>("collect_machine_profile")
|
||||
setProfile(p)
|
||||
|
|
@ -218,6 +233,71 @@ function App() {
|
|||
return normalized
|
||||
}, [config?.appUrl])
|
||||
|
||||
useEffect(() => {
|
||||
const trimmedSecret = provisioningSecret.trim()
|
||||
const query = company.trim()
|
||||
if (!trimmedSecret || query.length < 2) {
|
||||
setCompanyOptions([])
|
||||
setCompanyLookupPending(false)
|
||||
return
|
||||
}
|
||||
if (selectedCompany && selectedCompany.name.toLowerCase() === query.toLowerCase()) {
|
||||
setCompanyOptions([])
|
||||
setCompanyLookupPending(false)
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(async () => {
|
||||
setCompanyLookupPending(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set("search", query)
|
||||
const tenant = tenantId.trim()
|
||||
if (tenant) params.set("tenantId", tenant)
|
||||
const res = await fetch(`${apiBaseUrl}/api/machines/companies?${params.toString()}`, {
|
||||
headers: {
|
||||
"X-Machine-Secret": trimmedSecret,
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
setCompanyOptions([])
|
||||
return
|
||||
}
|
||||
const data = (await res.json()) as { companies?: CompanyOption[] }
|
||||
setCompanyOptions(data.companies ?? [])
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return
|
||||
if (typeof error === "object" && error && "name" in error && (error as { name?: string }).name === "AbortError") return
|
||||
console.error("Falha ao buscar empresas", error)
|
||||
} finally {
|
||||
setCompanyLookupPending(false)
|
||||
}
|
||||
}, 200)
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
controller.abort()
|
||||
setCompanyLookupPending(false)
|
||||
}
|
||||
}, [company, tenantId, provisioningSecret, selectedCompany])
|
||||
|
||||
function handleSelectCompany(option: CompanyOption) {
|
||||
setCompany(option.name)
|
||||
setSelectedCompany(option)
|
||||
setCompanyOptions([])
|
||||
if (!tenantId.trim()) {
|
||||
setTenantId(option.tenantId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCompanyInputChange(value: string) {
|
||||
setCompany(value)
|
||||
setSelectedCompany((prev) => {
|
||||
if (!prev) return null
|
||||
return prev.name.toLowerCase() === value.trim().toLowerCase() ? prev : null
|
||||
})
|
||||
}
|
||||
|
||||
async function register() {
|
||||
if (!profile) return
|
||||
if (!provisioningSecret.trim()) { setError("Informe o código de provisionamento."); return }
|
||||
|
|
@ -226,11 +306,55 @@ function App() {
|
|||
setError("Informe o e-mail do colaborador ou gestor para vincular esta máquina.")
|
||||
return
|
||||
}
|
||||
const normalizedName = collabName.trim()
|
||||
if (!normalizedName) {
|
||||
setError("Informe o nome completo do colaborador ou gestor.")
|
||||
return
|
||||
}
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const trimmedTenantId = tenantId.trim()
|
||||
const trimmedCompanyName = company.trim()
|
||||
let ensuredCompany: { name: string; slug: string } | null = null
|
||||
if (trimmedCompanyName) {
|
||||
if (selectedCompany && selectedCompany.name.toLowerCase() === trimmedCompanyName.toLowerCase()) {
|
||||
ensuredCompany = { name: selectedCompany.name, slug: selectedCompany.slug }
|
||||
} else {
|
||||
const ensureRes = await fetch(`${apiBaseUrl}/api/machines/companies`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Machine-Secret": provisioningSecret.trim(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenantId: trimmedTenantId || undefined,
|
||||
name: trimmedCompanyName,
|
||||
}),
|
||||
})
|
||||
if (!ensureRes.ok) {
|
||||
const text = await ensureRes.text()
|
||||
throw new Error(`Falha ao preparar empresa (${ensureRes.status}): ${text.slice(0, 300)}`)
|
||||
}
|
||||
const ensureData = (await ensureRes.json()) as { company?: { name: string; slug: string; tenantId?: string } }
|
||||
if (!ensureData.company?.slug) {
|
||||
throw new Error("Resposta inválida ao preparar empresa.")
|
||||
}
|
||||
ensuredCompany = { name: ensureData.company.name, slug: ensureData.company.slug }
|
||||
setSelectedCompany({
|
||||
id: ensureData.company.slug,
|
||||
name: ensureData.company.name,
|
||||
slug: ensureData.company.slug,
|
||||
tenantId: ensureData.company.tenantId ?? (trimmedTenantId || DEFAULT_TENANT_ID),
|
||||
})
|
||||
if (!trimmedTenantId && ensureData.company.tenantId) {
|
||||
setTenantId(ensureData.company.tenantId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const collaboratorPayload = {
|
||||
email: normalizedEmail,
|
||||
name: collabName.trim() || undefined,
|
||||
name: normalizedName,
|
||||
}
|
||||
const collaboratorMetadata = collaboratorPayload
|
||||
? { ...collaboratorPayload, role: accessRole }
|
||||
|
|
@ -244,8 +368,8 @@ function App() {
|
|||
}
|
||||
const payload = {
|
||||
provisioningSecret: provisioningSecret.trim(),
|
||||
tenantId: tenantId.trim() || undefined,
|
||||
companySlug: company.trim() || undefined,
|
||||
tenantId: trimmedTenantId || undefined,
|
||||
companySlug: ensuredCompany?.slug,
|
||||
hostname: profile.hostname,
|
||||
os: profile.os,
|
||||
macAddresses: profile.macAddresses,
|
||||
|
|
@ -266,7 +390,8 @@ function App() {
|
|||
const cfg: AgentConfig = {
|
||||
machineId: data.machineId,
|
||||
tenantId: data.tenantId ?? null,
|
||||
companySlug: data.companySlug ?? null,
|
||||
companySlug: data.companySlug ?? ensuredCompany?.slug ?? null,
|
||||
companyName: ensuredCompany?.name ?? (trimmedCompanyName || null),
|
||||
machineEmail: data.machineEmail ?? null,
|
||||
collaboratorEmail: collaboratorPayload?.email ?? null,
|
||||
collaboratorName: collaboratorPayload?.name ?? null,
|
||||
|
|
@ -282,6 +407,9 @@ function App() {
|
|||
}
|
||||
await writeConfig(store, cfg)
|
||||
setConfig(cfg); setToken(data.machineToken)
|
||||
if (ensuredCompany?.name) {
|
||||
setCompany(ensuredCompany.name)
|
||||
}
|
||||
await invoke("start_machine_agent", { baseUrl: apiBaseUrl, token: data.machineToken, status: "online", intervalSeconds: 300 })
|
||||
setStatus("online")
|
||||
} catch (err) {
|
||||
|
|
@ -293,6 +421,7 @@ function App() {
|
|||
|
||||
const openSystem = useCallback(() => {
|
||||
if (!token) return
|
||||
setIsLaunchingSystem(true)
|
||||
const persona = (config?.accessRole ?? accessRole) === "manager" ? "manager" : "collaborator"
|
||||
const redirectTarget = persona === "manager" ? "/dashboard" : "/portal"
|
||||
const url = `${resolvedAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}`
|
||||
|
|
@ -304,6 +433,8 @@ function App() {
|
|||
await store.delete("token"); await store.delete("config"); await store.save()
|
||||
autoLaunchRef.current = false
|
||||
setToken(null); setConfig(null); setStatus(null); setAccessRole("collaborator")
|
||||
setCompany(""); setSelectedCompany(null); setCompanyOptions([])
|
||||
setIsLaunchingSystem(false)
|
||||
const p = await invoke<MachineProfile>("collect_machine_profile")
|
||||
setProfile(p)
|
||||
}
|
||||
|
|
@ -397,9 +528,22 @@ function App() {
|
|||
if (!token) return
|
||||
if (autoLaunchRef.current) return
|
||||
autoLaunchRef.current = true
|
||||
setIsLaunchingSystem(true)
|
||||
openSystem()
|
||||
}, [token, config?.accessRole, openSystem])
|
||||
|
||||
if (isLaunchingSystem && token) {
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center bg-slate-50 p-6">
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-slate-200 bg-white px-8 py-10 shadow-sm">
|
||||
<Loader2 className="size-6 animate-spin text-neutral-700" />
|
||||
<p className="text-sm font-medium text-neutral-800">Abrindo plataforma da Rever…</p>
|
||||
<p className="text-xs text-neutral-500">Aguarde só um instante.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center p-6">
|
||||
<div className="w-full max-w-[720px] rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
|
|
@ -425,8 +569,34 @@ function App() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Empresa (slug opcional)</label>
|
||||
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="ex.: atlas-engenharia" value={company} onChange={(e)=>setCompany(e.target.value)} />
|
||||
<label className="text-sm font-medium">Empresa (nome ou slug, opcional)</label>
|
||||
<input
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
|
||||
placeholder="ex.: Paulicon Contábil"
|
||||
value={company}
|
||||
onChange={(e) => handleCompanyInputChange(e.target.value)}
|
||||
/>
|
||||
{companyLookupPending ? (
|
||||
<p className="text-xs text-slate-500">Buscando empresas...</p>
|
||||
) : companyOptions.length > 0 ? (
|
||||
<div className="max-h-48 w-full overflow-y-auto rounded-md border border-slate-200 bg-white text-sm shadow-sm">
|
||||
{companyOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className="flex w-full flex-col gap-0.5 px-3 py-2 text-left hover:bg-slate-100"
|
||||
onClick={() => handleSelectCompany(option)}
|
||||
>
|
||||
<span className="font-medium text-slate-800">{option.name}</span>
|
||||
<span className="text-xs text-slate-500">{option.slug}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : company.trim().length >= 2 ? (
|
||||
<p className="text-xs text-slate-500">Empresa não encontrada — criaremos automaticamente ao registrar.</p>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">Pode informar o nome completo que transformamos no slug registrado.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Perfil de acesso</label>
|
||||
|
|
@ -449,7 +619,7 @@ function App() {
|
|||
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="colaborador@empresa.com" value={collabEmail} onChange={(e)=>setCollabEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Nome do colaborador (opcional)</label>
|
||||
<label className="text-sm font-medium">Nome do colaborador <span className="text-rose-500">*</span></label>
|
||||
<input className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" placeholder="Nome completo" value={collabName} onChange={(e)=>setCollabName(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,21 @@
|
|||
import { query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { requireStaff } from "./rbac";
|
||||
|
||||
function normalizeSlug(input?: string | null): string | undefined {
|
||||
if (!input) return undefined
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return undefined
|
||||
const ascii = trimmed
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[\u2013\u2014]/g, "-")
|
||||
const sanitized = ascii.replace(/[^\w\s-]/g, "").replace(/[_\s]+/g, "-")
|
||||
const collapsed = sanitized.replace(/-+/g, "-").toLowerCase()
|
||||
const normalized = collapsed.replace(/^-+|-+$/g, "")
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
export const list = query({
|
||||
args: { tenantId: v.string(), viewerId: v.id("users") },
|
||||
handler: async (ctx, { tenantId, viewerId }) => {
|
||||
|
|
@ -13,3 +27,56 @@ export const list = query({
|
|||
return companies.map((c) => ({ id: c._id, name: c.name, slug: c.slug }))
|
||||
},
|
||||
})
|
||||
|
||||
export const ensureProvisioned = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
slug: v.string(),
|
||||
name: v.string(),
|
||||
},
|
||||
handler: async (ctx, { tenantId, slug, name }) => {
|
||||
const normalizedSlug = normalizeSlug(slug)
|
||||
if (!normalizedSlug) {
|
||||
throw new ConvexError("Slug inválido")
|
||||
}
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
throw new ConvexError("Nome inválido")
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("companies")
|
||||
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", normalizedSlug))
|
||||
.unique()
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
id: existing._id,
|
||||
slug: existing.slug,
|
||||
name: existing.name,
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const id = await ctx.db.insert("companies", {
|
||||
tenantId,
|
||||
name: trimmedName,
|
||||
slug: normalizedSlug,
|
||||
isAvulso: false,
|
||||
contractedHoursPerMonth: undefined,
|
||||
cnpj: undefined,
|
||||
domain: undefined,
|
||||
phone: undefined,
|
||||
description: undefined,
|
||||
address: undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
slug: normalizedSlug,
|
||||
name: trimmedName,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,6 +16,20 @@ type NormalizedIdentifiers = {
|
|||
serials: string[]
|
||||
}
|
||||
|
||||
function normalizeCompanySlug(input?: string | null): string | undefined {
|
||||
if (!input) return undefined
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return undefined
|
||||
const ascii = trimmed
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[\u2013\u2014]/g, "-")
|
||||
const sanitized = ascii.replace(/[^\w\s-]/g, "").replace(/[_\s]+/g, "-")
|
||||
const collapsed = sanitized.replace(/-+/g, "-").toLowerCase()
|
||||
const normalized = collapsed.replace(/^-+|-+$/g, "")
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function getProvisioningSecret() {
|
||||
const secret = process.env["MACHINE_PROVISIONING_SECRET"]
|
||||
if (!secret) {
|
||||
|
|
@ -92,10 +106,11 @@ async function ensureCompany(
|
|||
tenantId: string,
|
||||
companySlug?: string
|
||||
): Promise<{ companyId?: Id<"companies">; companySlug?: string }> {
|
||||
if (!companySlug) return {}
|
||||
const normalized = normalizeCompanySlug(companySlug)
|
||||
if (!normalized) return {}
|
||||
const company = await ctx.db
|
||||
.query("companies")
|
||||
.withIndex("by_tenant_slug", (q: any) => q.eq("tenantId", tenantId).eq("slug", companySlug))
|
||||
.withIndex("by_tenant_slug", (q: any) => q.eq("tenantId", tenantId).eq("slug", normalized))
|
||||
.unique()
|
||||
if (!company) {
|
||||
throw new ConvexError("Empresa não encontrada para o tenant informado")
|
||||
|
|
@ -317,9 +332,10 @@ export const register = mutation({
|
|||
}
|
||||
|
||||
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
|
||||
const normalizedCompanySlug = normalizeCompanySlug(args.companySlug)
|
||||
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
|
||||
const fingerprint = computeFingerprint(tenantId, args.companySlug, args.hostname, identifiers)
|
||||
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug)
|
||||
const fingerprint = computeFingerprint(tenantId, normalizedCompanySlug, args.hostname, identifiers)
|
||||
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, normalizedCompanySlug)
|
||||
const now = Date.now()
|
||||
const metadataPatch = args.metadata && typeof args.metadata === "object" ? (args.metadata as Record<string, unknown>) : undefined
|
||||
|
||||
|
|
@ -454,9 +470,10 @@ export const upsertInventory = mutation({
|
|||
}
|
||||
|
||||
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
|
||||
const normalizedCompanySlug = normalizeCompanySlug(args.companySlug)
|
||||
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
|
||||
const fingerprint = computeFingerprint(tenantId, args.companySlug, args.hostname, identifiers)
|
||||
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug)
|
||||
const fingerprint = computeFingerprint(tenantId, normalizedCompanySlug, args.hostname, identifiers)
|
||||
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, normalizedCompanySlug)
|
||||
const now = Date.now()
|
||||
|
||||
const metadataPatch: Record<string, unknown> = {}
|
||||
|
|
|
|||
|
|
@ -18,20 +18,43 @@ async function loadUsers() {
|
|||
name: true,
|
||||
role: true,
|
||||
tenantId: true,
|
||||
machinePersona: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return users.map((user) => ({
|
||||
const domainUsers = await prisma.user.findMany({
|
||||
select: {
|
||||
email: true,
|
||||
companyId: true,
|
||||
company: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const domainByEmail = new Map(domainUsers.map((user) => [user.email.toLowerCase(), user]))
|
||||
|
||||
return users.map((user) => {
|
||||
const domain = domainByEmail.get(user.email.toLowerCase())
|
||||
const normalizedRole = (normalizeRole(user.role) ?? "agent") as RoleOption
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name ?? "",
|
||||
role: (normalizeRole(user.role) ?? "agent") as RoleOption,
|
||||
role: normalizedRole,
|
||||
tenantId: user.tenantId ?? DEFAULT_TENANT_ID,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
updatedAt: user.updatedAt?.toISOString() ?? null,
|
||||
}))
|
||||
companyId: domain?.companyId ?? null,
|
||||
companyName: domain?.company?.name ?? null,
|
||||
machinePersona: user.machinePersona ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function loadInvites(): Promise<NormalizedInvite[]> {
|
||||
|
|
|
|||
66
src/app/api/admin/users/[id]/reset-password/route.ts
Normal file
66
src/app/api/admin/users/[id]/reset-password/route.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { NextResponse } from "next/server"
|
||||
|
||||
import { hashPassword } from "better-auth/crypto"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { assertAdminSession } from "@/lib/auth-server"
|
||||
|
||||
function generatePassword(length = 12) {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
let result = ""
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const randomIndex = Math.floor(Math.random() * alphabet.length)
|
||||
result += alphabet[randomIndex]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function POST(request: Request, { params }: { params: { id: string } }) {
|
||||
const session = await assertAdminSession()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const user = await prisma.authUser.findUnique({
|
||||
where: { id: params.id },
|
||||
select: { id: true, role: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 })
|
||||
}
|
||||
|
||||
if ((user.role ?? "").toLowerCase() === "machine") {
|
||||
return NextResponse.json({ error: "Contas de máquina não possuem senha web" }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as { password?: string } | null
|
||||
const temporaryPassword = body?.password?.trim() || generatePassword()
|
||||
const hashedPassword = await hashPassword(temporaryPassword)
|
||||
|
||||
const credentialAccount = await prisma.authAccount.findFirst({
|
||||
where: { userId: user.id, providerId: "credential" },
|
||||
})
|
||||
|
||||
if (credentialAccount) {
|
||||
await prisma.authAccount.update({ where: { id: credentialAccount.id }, data: { password: hashedPassword } })
|
||||
} else {
|
||||
// se a conta não existir, cria automaticamente
|
||||
const authUser = await prisma.authUser.findUnique({ where: { id: user.id } })
|
||||
if (!authUser) {
|
||||
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 })
|
||||
}
|
||||
await prisma.authAccount.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
providerId: "credential",
|
||||
accountId: authUser.email,
|
||||
password: hashedPassword,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ temporaryPassword })
|
||||
}
|
||||
207
src/app/api/admin/users/[id]/route.ts
Normal file
207
src/app/api/admin/users/[id]/route.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { assertAdminSession } from "@/lib/auth-server"
|
||||
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
|
||||
|
||||
function normalizeRole(input: string | null | undefined): RoleOption {
|
||||
const candidate = (input ?? "agent").toLowerCase() as RoleOption
|
||||
return ((ROLE_OPTIONS as readonly string[]).includes(candidate) ? candidate : "agent") as RoleOption
|
||||
}
|
||||
|
||||
function mapToUserRole(role: RoleOption) {
|
||||
const value = role.toUpperCase()
|
||||
if (["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"].includes(value)) {
|
||||
return value
|
||||
}
|
||||
return "AGENT"
|
||||
}
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function GET(_: Request, { params }: { params: { id: string } }) {
|
||||
const session = await assertAdminSession()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const user = await prisma.authUser.findUnique({
|
||||
where: { id: params.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
tenantId: true,
|
||||
machinePersona: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 })
|
||||
}
|
||||
|
||||
const domain = await prisma.user.findUnique({
|
||||
where: { email: user.email },
|
||||
select: {
|
||||
companyId: true,
|
||||
company: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name ?? "",
|
||||
role: normalizeRole(user.role),
|
||||
tenantId: user.tenantId ?? DEFAULT_TENANT_ID,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
updatedAt: user.updatedAt?.toISOString() ?? null,
|
||||
companyId: domain?.companyId ?? null,
|
||||
companyName: domain?.company?.name ?? null,
|
||||
machinePersona: user.machinePersona ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
|
||||
const session = await assertAdminSession()
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const payload = (await request.json().catch(() => null)) as {
|
||||
name?: string
|
||||
email?: string
|
||||
role?: RoleOption
|
||||
tenantId?: string
|
||||
companyId?: string | null
|
||||
} | null
|
||||
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
|
||||
}
|
||||
|
||||
const user = await prisma.authUser.findUnique({ where: { id: params.id } })
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Usuário não encontrado" }, { status: 404 })
|
||||
}
|
||||
|
||||
const nextName = payload.name?.trim() ?? user.name ?? ""
|
||||
const nextEmail = (payload.email ?? user.email).trim().toLowerCase()
|
||||
const nextRole = normalizeRole(payload.role ?? user.role)
|
||||
const nextTenant = (payload.tenantId ?? user.tenantId ?? DEFAULT_TENANT_ID).trim() || DEFAULT_TENANT_ID
|
||||
const companyId = payload.companyId ? payload.companyId : null
|
||||
|
||||
if (!nextEmail || !nextEmail.includes("@")) {
|
||||
return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (nextRole === "machine") {
|
||||
return NextResponse.json({ error: "Ajustes de máquinas devem ser feitos em Admin ▸ Máquinas" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (nextEmail !== user.email) {
|
||||
const conflict = await prisma.authUser.findUnique({ where: { email: nextEmail } })
|
||||
if (conflict && conflict.id !== user.id) {
|
||||
return NextResponse.json({ error: "Já existe um usuário com este e-mail" }, { status: 409 })
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.authUser.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
name: nextName,
|
||||
email: nextEmail,
|
||||
role: nextRole,
|
||||
tenantId: nextTenant,
|
||||
},
|
||||
})
|
||||
|
||||
if (nextEmail !== user.email) {
|
||||
const credentialAccount = await prisma.authAccount.findFirst({
|
||||
where: { userId: user.id, providerId: "credential" },
|
||||
})
|
||||
if (credentialAccount) {
|
||||
await prisma.authAccount.update({ where: { id: credentialAccount.id }, data: { accountId: nextEmail } })
|
||||
}
|
||||
}
|
||||
|
||||
const previousEmail = user.email
|
||||
const domainUser = await prisma.user.findUnique({ where: { email: previousEmail } })
|
||||
const companyData = companyId
|
||||
? await prisma.company.findUnique({ where: { id: companyId } })
|
||||
: null
|
||||
|
||||
if (companyId && !companyData) {
|
||||
return NextResponse.json({ error: "Empresa não encontrada" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (domainUser) {
|
||||
await prisma.user.update({
|
||||
where: { id: domainUser.id },
|
||||
data: {
|
||||
email: nextEmail,
|
||||
name: nextName || domainUser.name,
|
||||
role: mapToUserRole(nextRole),
|
||||
tenantId: nextTenant,
|
||||
companyId: companyId ?? null,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await prisma.user.upsert({
|
||||
where: { email: nextEmail },
|
||||
update: {
|
||||
name: nextName || nextEmail,
|
||||
role: mapToUserRole(nextRole),
|
||||
tenantId: nextTenant,
|
||||
companyId: companyId ?? null,
|
||||
},
|
||||
create: {
|
||||
email: nextEmail,
|
||||
name: nextName || nextEmail,
|
||||
role: mapToUserRole(nextRole),
|
||||
tenantId: nextTenant,
|
||||
companyId: companyId ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (convexUrl) {
|
||||
try {
|
||||
const convex = new ConvexHttpClient(convexUrl)
|
||||
await convex.mutation(api.users.ensureUser, {
|
||||
tenantId: nextTenant,
|
||||
email: nextEmail,
|
||||
name: nextName || nextEmail,
|
||||
avatarUrl: updated.avatarUrl ?? undefined,
|
||||
role: nextRole.toUpperCase(),
|
||||
companyId: companyId ? (companyId as Id<"companies">) : undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("Falha ao sincronizar usuário no Convex", error)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: updated.id,
|
||||
email: nextEmail,
|
||||
name: nextName,
|
||||
role: nextRole,
|
||||
tenantId: nextTenant,
|
||||
createdAt: updated.createdAt.toISOString(),
|
||||
updatedAt: updated.updatedAt?.toISOString() ?? null,
|
||||
companyId,
|
||||
companyName: companyData?.name ?? null,
|
||||
machinePersona: updated.machinePersona ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -101,6 +101,21 @@ export async function POST(request: Request) {
|
|||
},
|
||||
})
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { email: user.email },
|
||||
update: {
|
||||
name: user.name ?? user.email,
|
||||
role: role.toUpperCase(),
|
||||
tenantId,
|
||||
},
|
||||
create: {
|
||||
email: user.email,
|
||||
name: user.name ?? user.email,
|
||||
role: role.toUpperCase(),
|
||||
tenantId,
|
||||
},
|
||||
})
|
||||
|
||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (convexUrl) {
|
||||
try {
|
||||
|
|
|
|||
191
src/app/api/machines/companies/route.ts
Normal file
191
src/app/api/machines/companies/route.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { env } from "@/lib/env"
|
||||
import { normalizeSlug, slugify } from "@/lib/slug"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
const CORS_METHODS = "GET, POST, OPTIONS"
|
||||
|
||||
function extractSecret(request: Request, url: URL): string | null {
|
||||
const header =
|
||||
request.headers.get("x-machine-secret") ??
|
||||
request.headers.get("x-machine-provisioning-secret") ??
|
||||
request.headers.get("x-provisioning-secret")
|
||||
if (header && header.trim()) return header.trim()
|
||||
|
||||
const auth = request.headers.get("authorization")
|
||||
if (auth && auth.toLowerCase().startsWith("bearer ")) {
|
||||
const token = auth.slice(7).trim()
|
||||
if (token) return token
|
||||
}
|
||||
|
||||
const querySecret = url.searchParams.get("provisioningSecret")
|
||||
if (querySecret && querySecret.trim()) return querySecret.trim()
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function ensureConvexCompany(params: { tenantId: string; slug: string; name: string }) {
|
||||
if (!env.NEXT_PUBLIC_CONVEX_URL) {
|
||||
throw new Error("Convex não configurado")
|
||||
}
|
||||
const client = new ConvexHttpClient(env.NEXT_PUBLIC_CONVEX_URL)
|
||||
await client.mutation(api.companies.ensureProvisioned, params)
|
||||
}
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const origin = request.headers.get("origin")
|
||||
const secret = extractSecret(request, url)
|
||||
const expectedSecret = env.MACHINE_PROVISIONING_SECRET
|
||||
if (!expectedSecret) {
|
||||
return jsonWithCors({ error: "Provisionamento não configurado" }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
if (!secret || secret !== expectedSecret) {
|
||||
return jsonWithCors({ error: "Não autorizado" }, 401, origin, CORS_METHODS)
|
||||
}
|
||||
|
||||
const tenantIdRaw = url.searchParams.get("tenantId") ?? ""
|
||||
const tenantId = tenantIdRaw.trim() || DEFAULT_TENANT_ID
|
||||
const search = url.searchParams.get("search")?.trim() ?? ""
|
||||
|
||||
try {
|
||||
const companies = await prisma.company.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
...(search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: "insensitive" } },
|
||||
{ slug: { contains: normalizeSlug(search) ?? slugify(search), mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
take: 20,
|
||||
})
|
||||
|
||||
return jsonWithCors(
|
||||
{
|
||||
companies: companies.map((company) => ({
|
||||
id: company.id,
|
||||
tenantId: company.tenantId,
|
||||
name: company.name,
|
||||
slug: company.slug,
|
||||
})),
|
||||
},
|
||||
200,
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[machines.companies] Falha ao listar empresas", error)
|
||||
return jsonWithCors({ error: "Falha ao buscar empresas" }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const origin = request.headers.get("origin")
|
||||
const secret = extractSecret(request, url)
|
||||
const expectedSecret = env.MACHINE_PROVISIONING_SECRET
|
||||
if (!expectedSecret) {
|
||||
return jsonWithCors({ error: "Provisionamento não configurado" }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
if (!secret || secret !== expectedSecret) {
|
||||
return jsonWithCors({ error: "Não autorizado" }, 401, origin, CORS_METHODS)
|
||||
}
|
||||
|
||||
let payload: Partial<{ tenantId?: string; name?: string; slug?: string }>
|
||||
try {
|
||||
payload = (await request.json()) as Partial<{ tenantId?: string; name?: string; slug?: string }>
|
||||
} catch (error) {
|
||||
return jsonWithCors(
|
||||
{ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) },
|
||||
400,
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
|
||||
const tenantId = payload?.tenantId?.trim() || DEFAULT_TENANT_ID
|
||||
const name = payload?.name?.trim() ?? ""
|
||||
const normalizedSlug = normalizeSlug(payload?.slug ?? name)
|
||||
if (!name) {
|
||||
return jsonWithCors({ error: "Informe o nome da empresa" }, 400, origin, CORS_METHODS)
|
||||
}
|
||||
if (!normalizedSlug) {
|
||||
return jsonWithCors({ error: "Não foi possível gerar um slug para a empresa" }, 400, origin, CORS_METHODS)
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await prisma.company.findFirst({
|
||||
where: { tenantId, slug: normalizedSlug },
|
||||
})
|
||||
|
||||
const company =
|
||||
existing ??
|
||||
(await prisma.company.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name,
|
||||
slug: normalizedSlug,
|
||||
},
|
||||
}))
|
||||
|
||||
await ensureConvexCompany({ tenantId, slug: company.slug, name: company.name })
|
||||
|
||||
return jsonWithCors(
|
||||
{
|
||||
company: {
|
||||
id: company.id,
|
||||
tenantId: company.tenantId,
|
||||
name: company.name,
|
||||
slug: company.slug,
|
||||
created: existing ? false : true,
|
||||
},
|
||||
},
|
||||
existing ? 200 : 201,
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
} catch (error) {
|
||||
const prismaError = error as { code?: string }
|
||||
if (prismaError?.code === "P2002") {
|
||||
try {
|
||||
const fallback = await prisma.company.findFirst({ where: { tenantId, slug: normalizedSlug } })
|
||||
if (fallback) {
|
||||
await ensureConvexCompany({ tenantId, slug: fallback.slug, name: fallback.name })
|
||||
return jsonWithCors(
|
||||
{
|
||||
company: {
|
||||
id: fallback.id,
|
||||
tenantId: fallback.tenantId,
|
||||
name: fallback.name,
|
||||
slug: fallback.slug,
|
||||
created: false,
|
||||
},
|
||||
},
|
||||
200,
|
||||
origin,
|
||||
CORS_METHODS
|
||||
)
|
||||
}
|
||||
} catch (lookupError) {
|
||||
console.error("[machines.companies] Falha ao recuperar empresa após conflito", lookupError)
|
||||
}
|
||||
}
|
||||
console.error("[machines.companies] Falha ao criar empresa", error)
|
||||
return jsonWithCors({ error: "Falha ao criar ou recuperar empresa" }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { env } from "@/lib/env"
|
|||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { ensureMachineAccount } from "@/server/machines-auth"
|
||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
import { normalizeSlug } from "@/lib/slug"
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
|
|
@ -27,7 +28,7 @@ const registerSchema = z
|
|||
collaborator: z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
name: z.string().min(1, "Informe o nome do colaborador/gestor"),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
|
@ -66,11 +67,13 @@ export async function POST(request: Request) {
|
|||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
let normalizedCompanySlug: string | undefined
|
||||
|
||||
try {
|
||||
const tenantId = payload.tenantId ?? DEFAULT_TENANT_ID
|
||||
const persona = payload.accessRole ?? undefined
|
||||
const collaborator = payload.collaborator ?? null
|
||||
normalizedCompanySlug = normalizeSlug(payload.companySlug)
|
||||
|
||||
if (persona && !collaborator) {
|
||||
return jsonWithCors(
|
||||
|
|
@ -99,7 +102,7 @@ export async function POST(request: Request) {
|
|||
const registration = await client.mutation(api.machines.register, {
|
||||
provisioningSecret: payload.provisioningSecret,
|
||||
tenantId,
|
||||
companySlug: payload.companySlug ?? undefined,
|
||||
companySlug: normalizedCompanySlug,
|
||||
hostname: payload.hostname,
|
||||
os: payload.os,
|
||||
macAddresses: payload.macAddresses,
|
||||
|
|
@ -179,6 +182,10 @@ export async function POST(request: Request) {
|
|||
const isInvalidSecret = msg.includes("código de provisionamento inválido")
|
||||
const isConvexError = msg.includes("convexerror")
|
||||
const status = isCompanyNotFound ? 404 : isInvalidSecret ? 401 : isConvexError ? 400 : 500
|
||||
return jsonWithCors({ error: "Falha ao provisionar máquina", details }, status, request.headers.get("origin"), CORS_METHODS)
|
||||
const payload = { error: "Falha ao provisionar máquina", details } as Record<string, unknown>
|
||||
if (isCompanyNotFound && normalizedCompanySlug) {
|
||||
payload["companySlug"] = normalizedCompanySlug
|
||||
}
|
||||
return jsonWithCors(payload, status, request.headers.get("origin"), CORS_METHODS)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useEffect, useMemo, useState, useTransition } from "react"
|
||||
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -16,6 +17,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
|
||||
|
||||
|
|
@ -27,6 +29,9 @@ type AdminUser = {
|
|||
tenantId: string
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
companyId: string | null
|
||||
companyName: string | null
|
||||
machinePersona: string | null
|
||||
}
|
||||
|
||||
type AdminInvite = {
|
||||
|
|
@ -48,6 +53,11 @@ type AdminInvite = {
|
|||
revokedReason: string | null
|
||||
}
|
||||
|
||||
type CompanyOption = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
initialUsers: AdminUser[]
|
||||
initialInvites: AdminInvite[]
|
||||
|
|
@ -68,6 +78,11 @@ function formatRole(role: string) {
|
|||
return ROLE_LABELS[key] ?? role
|
||||
}
|
||||
|
||||
function normalizeRoleValue(role: string | null | undefined): RoleOption {
|
||||
const candidate = (role ?? "agent").toLowerCase() as RoleOption
|
||||
return ((ROLE_OPTIONS as readonly string[]).includes(candidate) ? candidate : "agent") as RoleOption
|
||||
}
|
||||
|
||||
function formatTenantLabel(tenantId: string, defaultTenantId: string) {
|
||||
if (!tenantId) return "Principal"
|
||||
if (tenantId === defaultTenantId) return "Principal"
|
||||
|
|
@ -104,46 +119,85 @@ function sanitizeInvite(invite: AdminInvite & { events?: unknown }): AdminInvite
|
|||
}
|
||||
|
||||
export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId }: Props) {
|
||||
const [users] = useState<AdminUser[]>(initialUsers)
|
||||
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
|
||||
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
|
||||
const [companies, setCompanies] = useState<CompanyOption[]>([])
|
||||
|
||||
const [email, setEmail] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [role, setRole] = useState<RoleOption>("agent")
|
||||
const [tenantId, setTenantId] = useState(defaultTenantId)
|
||||
const [expiresInDays, setExpiresInDays] = useState<string>("7")
|
||||
const [expiresInDays, setExpiresInDays] = useState("7")
|
||||
const [lastInviteLink, setLastInviteLink] = useState<string | null>(null)
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [companies, setCompanies] = useState<Array<{ id: string; name: string }>>([])
|
||||
|
||||
const [linkEmail, setLinkEmail] = useState("")
|
||||
const [linkCompanyId, setLinkCompanyId] = useState("")
|
||||
const [assigningCompany, setAssigningCompany] = useState(false)
|
||||
|
||||
const [editUserId, setEditUserId] = useState<string | null>(null)
|
||||
const editUser = useMemo(() => users.find((user) => user.id === editUserId) ?? null, [users, editUserId])
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
role: "agent" as RoleOption,
|
||||
tenantId: defaultTenantId,
|
||||
companyId: "",
|
||||
})
|
||||
const [isSavingUser, setIsSavingUser] = useState(false)
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false)
|
||||
const [passwordPreview, setPasswordPreview] = useState<string | null>(null)
|
||||
|
||||
const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
|
||||
const teamUsers = useMemo(() => users.filter((user) => user.role !== "machine"), [users])
|
||||
const machineUsers = useMemo(() => users.filter((user) => user.role === "machine"), [users])
|
||||
|
||||
// load companies for association
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await fetch("/api/admin/companies", { credentials: "include" })
|
||||
const j = (await r.json()) as { companies?: Array<{ id: string; name: string }> }
|
||||
const items = (j.companies ?? []).map((c) => ({ id: c.id, name: c.name }))
|
||||
setCompanies(items)
|
||||
} catch {
|
||||
// noop
|
||||
const response = await fetch("/api/admin/companies", { credentials: "include" })
|
||||
const json = (await response.json()) as { companies?: CompanyOption[] }
|
||||
const mapped = (json.companies ?? []).map((company) => ({ id: company.id, name: company.name }))
|
||||
setCompanies(mapped)
|
||||
if (mapped.length > 0 && !linkCompanyId) {
|
||||
setLinkCompanyId(mapped[0].id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Falha ao carregar empresas", error)
|
||||
}
|
||||
})()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editUser) {
|
||||
setEditForm({ name: "", email: "", role: "agent", tenantId: defaultTenantId, companyId: "" })
|
||||
setPasswordPreview(null)
|
||||
return
|
||||
}
|
||||
|
||||
setEditForm({
|
||||
name: editUser.name || "",
|
||||
email: editUser.email,
|
||||
role: editUser.role,
|
||||
tenantId: editUser.tenantId || defaultTenantId,
|
||||
companyId: editUser.companyId ?? "",
|
||||
})
|
||||
setPasswordPreview(null)
|
||||
}, [editUser, defaultTenantId])
|
||||
|
||||
async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!email || !email.includes("@")) {
|
||||
const normalizedEmail = email.trim().toLowerCase()
|
||||
if (!normalizedEmail || !normalizedEmail.includes("@")) {
|
||||
toast.error("Informe um e-mail válido")
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
email,
|
||||
name,
|
||||
email: normalizedEmail,
|
||||
name: name.trim(),
|
||||
role,
|
||||
tenantId,
|
||||
expiresInDays: Number.parseInt(expiresInDays, 10),
|
||||
|
|
@ -188,9 +242,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
|
||||
async function handleRevoke(inviteId: string) {
|
||||
const invite = invites.find((item) => item.id === inviteId)
|
||||
if (!invite || invite.status !== "pending") {
|
||||
return
|
||||
}
|
||||
if (!invite || invite.status !== "pending") return
|
||||
|
||||
const confirmed = window.confirm("Deseja revogar este convite?")
|
||||
if (!confirmed) return
|
||||
|
|
@ -220,20 +272,270 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
}
|
||||
}
|
||||
|
||||
async function handleAssignCompany(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const normalizedEmail = linkEmail.trim().toLowerCase()
|
||||
if (!normalizedEmail || !normalizedEmail.includes("@")) {
|
||||
toast.error("Informe um e-mail válido para vincular")
|
||||
return
|
||||
}
|
||||
if (!linkCompanyId) {
|
||||
toast.error("Selecione a empresa para vincular")
|
||||
return
|
||||
}
|
||||
|
||||
setAssigningCompany(true)
|
||||
toast.loading("Vinculando colaborador...", { id: "assign-company" })
|
||||
try {
|
||||
const response = await fetch("/api/admin/users/assign-company", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: normalizedEmail, companyId: linkCompanyId }),
|
||||
credentials: "include",
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error ?? "Falha ao vincular")
|
||||
}
|
||||
toast.success("Colaborador vinculado com sucesso", { id: "assign-company" })
|
||||
setLinkEmail("")
|
||||
setLinkCompanyId("")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Não foi possível vincular"
|
||||
toast.error(message, { id: "assign-company" })
|
||||
} finally {
|
||||
setAssigningCompany(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveUser(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!editUser) return
|
||||
|
||||
const payload = {
|
||||
name: editForm.name.trim(),
|
||||
email: editForm.email.trim().toLowerCase(),
|
||||
role: editForm.role,
|
||||
tenantId: editForm.tenantId.trim() || defaultTenantId,
|
||||
companyId: editForm.companyId || null,
|
||||
}
|
||||
|
||||
if (!payload.name) {
|
||||
toast.error("Informe o nome do usuário")
|
||||
return
|
||||
}
|
||||
if (!payload.email || !payload.email.includes("@")) {
|
||||
toast.error("Informe um e-mail válido")
|
||||
return
|
||||
}
|
||||
|
||||
setIsSavingUser(true)
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${editUser.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error ?? "Não foi possível atualizar o usuário")
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { user: AdminUser }
|
||||
setUsers((previous) => previous.map((item) => (item.id === data.user.id ? data.user : item)))
|
||||
toast.success("Usuário atualizado com sucesso")
|
||||
setEditUserId(null)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao salvar alterações"
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setIsSavingUser(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
if (!editUser) return
|
||||
setIsResettingPassword(true)
|
||||
toast.loading("Gerando nova senha...", { id: "reset-password" })
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${editUser.id}/reset-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error ?? "Falha ao gerar nova senha")
|
||||
}
|
||||
const data = (await response.json()) as { temporaryPassword: string }
|
||||
setPasswordPreview(data.temporaryPassword)
|
||||
toast.success("Senha temporária criada", { id: "reset-password" })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao gerar senha"
|
||||
toast.error(message, { id: "reset-password" })
|
||||
} finally {
|
||||
setIsResettingPassword(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isMachineEditing = editUser?.role === "machine"
|
||||
const companyOptions = useMemo(() => [
|
||||
{ id: "", name: "Sem empresa vinculada" },
|
||||
...companies,
|
||||
], [companies])
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="invites" className="w-full">
|
||||
<>
|
||||
<Tabs defaultValue="users" className="w-full">
|
||||
<TabsList className="h-12 w-full justify-start rounded-xl bg-slate-100 p-1">
|
||||
<TabsTrigger value="users" className="rounded-lg">Equipe</TabsTrigger>
|
||||
<TabsTrigger value="machines" className="rounded-lg">Agentes de máquina</TabsTrigger>
|
||||
<TabsTrigger value="invites" className="rounded-lg">Convites</TabsTrigger>
|
||||
<TabsTrigger value="users" className="rounded-lg">Usuários</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users" className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Equipe cadastrada</CardTitle>
|
||||
<CardDescription>Usuários com acesso ao sistema. Edite papéis, dados pessoais ou gere uma nova senha.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="py-3 pr-4 font-medium">Nome</th>
|
||||
<th className="py-3 pr-4 font-medium">E-mail</th>
|
||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||
<th className="py-3 pr-4 font-medium">Empresa</th>
|
||||
<th className="py-3 pr-4 font-medium">Espaço</th>
|
||||
<th className="py-3 pr-4 font-medium">Criado em</th>
|
||||
<th className="py-3 font-medium">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{teamUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatRole(user.role)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.companyName ?? "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(user.tenantId, defaultTenantId)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
<td className="py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setEditUserId(user.id)}>
|
||||
Editar
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{teamUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-6 text-center text-neutral-500">
|
||||
Nenhum usuário cadastrado até o momento.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vincular usuário a empresa</CardTitle>
|
||||
<CardDescription>Associe colaboradores existentes a uma empresa para liberar painéis de gestores e filtros específicos.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleAssignCompany} className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto]">
|
||||
<div className="grid gap-2">
|
||||
<Label>E-mail do usuário</Label>
|
||||
<Input
|
||||
value={linkEmail}
|
||||
onChange={(event) => setLinkEmail(event.target.value)}
|
||||
placeholder="colaborador@empresa.com"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Empresa</Label>
|
||||
<Select value={linkCompanyId} onValueChange={setLinkCompanyId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecionar" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies.map((company) => (
|
||||
<SelectItem key={company.id} value={company.id}>
|
||||
{company.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="submit" disabled={assigningCompany || companies.length === 0}>
|
||||
{assigningCompany ? "Vinculando..." : "Vincular"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="mt-2 text-xs text-neutral-500">Caso a empresa ainda não exista, cadastre-a em <Link href="/admin/companies" className="underline underline-offset-4">Admin ▸ Empresas & clientes</Link>.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="machines" className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agentes de máquina</CardTitle>
|
||||
<CardDescription>Contas provisionadas automaticamente via agente desktop. Ajustes de vínculo podem ser feitos em <Link href="/admin/machines" className="underline underline-offset-4">Admin ▸ Máquinas</Link>.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="py-3 pr-4 font-medium">Identificação</th>
|
||||
<th className="py-3 pr-4 font-medium">E-mail técnico</th>
|
||||
<th className="py-3 pr-4 font-medium">Perfil</th>
|
||||
<th className="py-3 pr-4 font-medium">Criado em</th>
|
||||
<th className="py-3 font-medium">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{machineUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "Máquina"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.machinePersona ? user.machinePersona === "manager" ? "Gestor" : "Colaborador" : "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
<td className="py-3">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/machines">Gerenciar</Link>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{machineUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-6 text-center text-neutral-500">
|
||||
Nenhuma máquina provisionada ainda.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="invites" className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Gerar convite</CardTitle>
|
||||
<CardDescription>
|
||||
Envie convites personalizados com validade controlada e acompanhe o status em tempo real.
|
||||
</CardDescription>
|
||||
<CardDescription>Envie convites personalizados com validade controlada e acompanhe o status em tempo real.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
|
|
@ -270,9 +572,11 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{normalizedRoles.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{formatRole(item)}
|
||||
{normalizedRoles
|
||||
.filter((option) => option !== "machine")
|
||||
.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{formatRole(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -313,74 +617,15 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<div className="mt-4 flex flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900">Link de convite pronto</p>
|
||||
<p className="text-neutral-600">Compartilhe com o convidado. O link expira automaticamente no prazo definido.</p>
|
||||
<p className="mt-2 truncate font-mono text-xs text-neutral-500">{lastInviteLink}</p>
|
||||
<p className="text-neutral-600">Compartilhe com o convidado. O link expira automaticamente no prazo selecionado.</p>
|
||||
<code className="mt-2 block rounded bg-white px-3 py-1 text-xs text-neutral-700">{lastInviteLink}</code>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => handleCopy(lastInviteLink)}>
|
||||
Copiar link
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => handleCopy(lastInviteLink)}>Copiar link</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vincular usuário a empresa</CardTitle>
|
||||
<CardDescription>Associe um colaborador à sua empresa (usado para escopo de gestores e relatórios).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
className="grid grid-cols-1 gap-4 md:grid-cols-[1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (!linkEmail || !linkCompanyId) {
|
||||
toast.error("Informe e-mail e empresa")
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
toast.loading("Vinculando...", { id: "assign-company" })
|
||||
try {
|
||||
const r = await fetch("/api/admin/users/assign-company", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: linkEmail, companyId: linkCompanyId }),
|
||||
credentials: "include",
|
||||
})
|
||||
if (!r.ok) throw new Error("failed")
|
||||
toast.success("Usuário vinculado à empresa!", { id: "assign-company" })
|
||||
} catch {
|
||||
toast.error("Não foi possível vincular", { id: "assign-company" })
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<Label>E-mail do usuário</Label>
|
||||
<Input value={linkEmail} onChange={(e) => setLinkEmail(e.target.value)} placeholder="colaborador@empresa.com" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Empresa</Label>
|
||||
<Select value={linkCompanyId} onValueChange={setLinkCompanyId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecionar" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="submit" disabled={isPending}>Vincular</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Convites emitidos</CardTitle>
|
||||
|
|
@ -450,47 +695,132 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Equipe cadastrada</CardTitle>
|
||||
<CardDescription>Usuários ativos e provisionados via convites aceitos.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full table-fixed divide-y divide-slate-200 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500">
|
||||
<th className="py-3 pr-4 font-medium">Nome</th>
|
||||
<th className="py-3 pr-4 font-medium">E-mail</th>
|
||||
<th className="py-3 pr-4 font-medium">Papel</th>
|
||||
<th className="py-3 pr-4 font-medium">Espaço</th>
|
||||
<th className="py-3 font-medium">Criado em</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatRole(user.role)}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{formatTenantLabel(user.tenantId, defaultTenantId)}</td>
|
||||
<td className="py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-6 text-center text-neutral-500">
|
||||
Nenhum usuário cadastrado até o momento.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
|
||||
<Sheet open={Boolean(editUser)} onOpenChange={(open) => (!open ? setEditUserId(null) : null)}>
|
||||
<SheetContent position="right" size="lg" className="space-y-6 overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Editar usuário</SheetTitle>
|
||||
<SheetDescription>Atualize os dados cadastrais, papel e vínculo do colaborador.</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{editUser ? (
|
||||
<form onSubmit={handleSaveUser} className="space-y-6">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Nome</Label>
|
||||
<Input
|
||||
value={editForm.name}
|
||||
onChange={(event) => setEditForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
placeholder="Nome completo"
|
||||
disabled={isSavingUser || isMachineEditing}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>E-mail</Label>
|
||||
<Input
|
||||
value={editForm.email}
|
||||
onChange={(event) => setEditForm((prev) => ({ ...prev, email: event.target.value }))}
|
||||
type="email"
|
||||
disabled={isSavingUser || isMachineEditing}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Papel</Label>
|
||||
<Select
|
||||
value={editForm.role}
|
||||
onValueChange={(value) => setEditForm((prev) => ({ ...prev, role: value as RoleOption }))}
|
||||
disabled={isSavingUser || isMachineEditing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{normalizedRoles
|
||||
.filter((option) => option !== "machine")
|
||||
.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{formatRole(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Espaço (tenant)</Label>
|
||||
<Input
|
||||
value={editForm.tenantId}
|
||||
onChange={(event) => setEditForm((prev) => ({ ...prev, tenantId: event.target.value }))}
|
||||
placeholder="tenant-atlas"
|
||||
disabled={isSavingUser || isMachineEditing}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Empresa vinculada</Label>
|
||||
<Select
|
||||
value={editForm.companyId}
|
||||
onValueChange={(value) => setEditForm((prev) => ({ ...prev, companyId: value }))}
|
||||
disabled={isSavingUser}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companyOptions.map((company) => (
|
||||
<SelectItem key={company.id} value={company.id}>
|
||||
{company.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-neutral-500">Essa seleção substitui o vínculo atual no portal do cliente.</p>
|
||||
</div>
|
||||
{isMachineEditing ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-3 text-sm text-neutral-600">
|
||||
Os ajustes detalhados de agentes de máquina são feitos em <Link href="/admin/machines" className="underline underline-offset-4">Admin ▸ Máquinas</Link>.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm text-neutral-700">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900">Gerar nova senha</p>
|
||||
<p className="text-xs text-neutral-500">Uma senha temporária será exibida abaixo. Compartilhe com o usuário e peça para trocá-la no primeiro acesso.</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={handleResetPassword} disabled={isResettingPassword}>
|
||||
{isResettingPassword ? "Gerando..." : "Gerar senha"}
|
||||
</Button>
|
||||
</div>
|
||||
{passwordPreview ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 rounded-md border border-slate-300 bg-white px-3 py-2">
|
||||
<code className="text-sm font-semibold text-neutral-900">{passwordPreview}</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigator.clipboard.writeText(passwordPreview).then(() => toast.success("Senha copiada"))}
|
||||
>
|
||||
Copiar
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="flex flex-col gap-2 sm:flex-row sm:gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => setEditUserId(null)} disabled={isSavingUser}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSavingUser || isMachineEditing} className="sm:ml-auto">
|
||||
{isSavingUser ? "Salvando..." : "Salvar alterações"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
UserPlus,
|
||||
BellRing,
|
||||
ChevronDown,
|
||||
ShieldCheck,
|
||||
} from "lucide-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
|
|
@ -73,7 +74,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
|
|||
url: "/tickets",
|
||||
icon: Ticket,
|
||||
requiredRole: "staff",
|
||||
children: [{ title: "Resolvidos", url: "/tickets/resolved", requiredRole: "staff" }],
|
||||
children: [{ title: "Resolvidos", url: "/tickets/resolved", icon: ShieldCheck, requiredRole: "staff" }],
|
||||
},
|
||||
{ title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" },
|
||||
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
|
||||
|
|
@ -263,6 +264,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
<SidebarMenuItem key={`${item.title}-${child.title}`}>
|
||||
<SidebarMenuButton asChild isActive={isActive(child)}>
|
||||
<a href={child.url} className="gap-2 pl-7 text-sm">
|
||||
{child.icon ? <child.icon className="size-3.5 text-neutral-500" /> : null}
|
||||
<span>{child.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
IconDotsVertical,
|
||||
|
|
@ -45,6 +45,12 @@ export function NavUser({ user }: NavUserProps) {
|
|||
const { isMobile } = useSidebar()
|
||||
const router = useRouter()
|
||||
const [isSigningOut, setIsSigningOut] = useState(false)
|
||||
const [isDesktopShell, setIsDesktopShell] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
setIsDesktopShell(Boolean((window as typeof window & { __TAURI__?: unknown }).__TAURI__))
|
||||
}, [])
|
||||
|
||||
const initials = useMemo(() => {
|
||||
const source = normalizedUser.name?.trim() || normalizedUser.email?.trim() || ""
|
||||
|
|
@ -135,6 +141,8 @@ export function NavUser({ user }: NavUserProps) {
|
|||
<span>Notificações (em breve)</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
{!isDesktopShell ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
|
|
@ -146,6 +154,8 @@ export function NavUser({ user }: NavUserProps) {
|
|||
<IconLogout className="size-4" />
|
||||
<span>{isSigningOut ? "Encerrando…" : "Encerrar sessão"}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
|
|
|
|||
|
|
@ -28,8 +28,12 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
|
||||
const isMachineSession = session?.user.role === "machine"
|
||||
const personaValue = machineContext?.persona ?? session?.user.machinePersona ?? null
|
||||
const displayName = machineContext?.assignedUserName ?? session?.user.name ?? session?.user.email ?? "Cliente"
|
||||
const displayEmail = machineContext?.assignedUserEmail ?? session?.user.email ?? ""
|
||||
const collaboratorName = machineContext?.assignedUserName?.trim() ?? ""
|
||||
const collaboratorEmail = machineContext?.assignedUserEmail?.trim() ?? ""
|
||||
const userName = session?.user.name?.trim() ?? ""
|
||||
const userEmail = session?.user.email?.trim() ?? ""
|
||||
const displayName = collaboratorName || userName || collaboratorEmail || userEmail || "Cliente"
|
||||
const displayEmail = collaboratorEmail || userEmail
|
||||
const personaLabel = personaValue === "manager" ? "Gestor" : "Colaborador"
|
||||
|
||||
const initials = useMemo(() => {
|
||||
|
|
@ -57,31 +61,40 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const isNavItemActive = (itemHref: string) => {
|
||||
if (itemHref === "/portal/tickets") {
|
||||
if (pathname === "/portal" || pathname === "/portal/tickets") return true
|
||||
if (/^\/portal\/tickets\/[A-Za-z0-9_-]+$/.test(pathname) && !pathname.endsWith("/new")) return true
|
||||
return false
|
||||
}
|
||||
return pathname === itemHref
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-gradient-to-b from-slate-50 via-slate-50 to-white">
|
||||
<header className="border-b border-slate-200 bg-white/90 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-4 px-6 py-4">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex size-9 items-center justify-center rounded-xl bg-neutral-900 text-white shadow-sm">
|
||||
<GalleryVerticalEnd className="size-4" />
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-neutral-500">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.08em] text-neutral-500 sm:tracking-[0.12em]">
|
||||
Portal do cliente
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-neutral-900">Raven</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex items-center gap-3 text-sm font-medium">
|
||||
<nav className="flex w-full flex-wrap items-center gap-2 text-sm font-medium sm:w-auto sm:justify-center">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`)
|
||||
const isActive = isNavItemActive(item.href)
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full px-4 py-2 transition",
|
||||
"inline-flex w-full items-center justify-center gap-2 rounded-full px-4 py-2 transition sm:w-auto",
|
||||
isActive
|
||||
? "bg-neutral-900 text-white shadow-sm"
|
||||
: "bg-transparent text-neutral-700 hover:bg-neutral-100"
|
||||
|
|
@ -93,7 +106,7 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex w-full flex-col items-start gap-3 sm:w-auto sm:flex-row sm:items-center">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Avatar className="size-9 border border-slate-200">
|
||||
<AvatarImage src={session?.user.avatarUrl ?? undefined} alt={displayName ?? ""} />
|
||||
|
|
@ -101,7 +114,7 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
</Avatar>
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-semibold text-neutral-900">{displayName}</span>
|
||||
<span className="text-xs text-neutral-500">{displayEmail}</span>
|
||||
<span className="text-xs text-neutral-500">{displayEmail || "Sem e-mail definido"}</span>
|
||||
{personaValue ? (
|
||||
<span className="text-[10px] uppercase tracking-wide text-neutral-400">{personaLabel}</span>
|
||||
) : null}
|
||||
|
|
@ -113,7 +126,7 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
variant="outline"
|
||||
onClick={handleSignOut}
|
||||
disabled={isSigningOut}
|
||||
className="inline-flex items-center gap-2"
|
||||
className="inline-flex items-center gap-2 self-stretch sm:self-auto"
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
Sair
|
||||
|
|
@ -127,9 +140,14 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
{children}
|
||||
</main>
|
||||
<footer className="border-t border-slate-200 bg-white/70">
|
||||
<div className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4 text-xs text-neutral-500">
|
||||
<span>© {new Date().getFullYear()} Raven</span>
|
||||
<span>Suporte: suporte@sistema.dev</span>
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-3 px-6 py-4 text-xs text-neutral-500">
|
||||
<span>© {new Date().getFullYear()} Raven — Desenvolvido pela Rever Tecnologia</span>
|
||||
<span>
|
||||
Suporte:{" "}
|
||||
<a href="mailto:suporte@rever.com.br" className="font-medium text-neutral-600 hover:text-neutral-800">
|
||||
suporte@rever.com.br
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -84,13 +84,16 @@ export function RecentTicketsPanel() {
|
|||
const { convexUserId } = useAuth()
|
||||
const ticketsRaw = useQuery(
|
||||
api.tickets.list,
|
||||
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users">, limit: 6 } : "skip"
|
||||
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users">, limit: 12 } : "skip"
|
||||
)
|
||||
const [enteringId, setEnteringId] = useState<string | null>(null)
|
||||
const previousIdsRef = useRef<string[]>([])
|
||||
|
||||
const tickets = useMemo(
|
||||
() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]).slice(0, 6),
|
||||
() =>
|
||||
mapTicketsFromServerList((ticketsRaw ?? []) as unknown[])
|
||||
.filter((ticket) => ticket.status !== "RESOLVED")
|
||||
.slice(0, 6),
|
||||
[ticketsRaw]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -133,37 +133,38 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
return (
|
||||
<Card className="gap-0 rounded-3xl border border-slate-200 bg-white py-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table className="min-w-full overflow-hidden rounded-3xl table-fixed">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table className="w-full min-w-[920px] overflow-hidden rounded-3xl">
|
||||
<TableHeader className="bg-slate-100/80">
|
||||
<TableRow className="bg-transparent text-[11px] uppercase tracking-wide text-neutral-600">
|
||||
<TableHead className="w-[120px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
|
||||
<TableHead className="w-[110px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 sm:w-[120px]">
|
||||
Ticket
|
||||
</TableHead>
|
||||
<TableHead className="w-[40%] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
|
||||
<TableHead className="w-[38%] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 sm:w-[40%]">
|
||||
Assunto
|
||||
</TableHead>
|
||||
<TableHead className="hidden w-[120px] pl-8 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
|
||||
<TableHead className="hidden w-[120px] pl-6 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
|
||||
Fila
|
||||
</TableHead>
|
||||
<TableHead className="hidden w-[80px] pl-8 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell">
|
||||
<TableHead className="hidden w-[70px] pl-6 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell sm:w-[80px]">
|
||||
Canal
|
||||
</TableHead>
|
||||
<TableHead className="hidden w-[180px] pl-8 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
|
||||
<TableHead className="hidden w-[150px] pl-6 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell xl:w-[180px]">
|
||||
Empresa
|
||||
</TableHead>
|
||||
<TableHead className="hidden w-[100px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell">
|
||||
<TableHead className="hidden w-[90px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell lg:w-[100px]">
|
||||
Prioridade
|
||||
</TableHead>
|
||||
<TableHead className="w-[230px] pl-14 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
|
||||
<TableHead className="w-[160px] pl-6 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 sm:w-[200px] lg:pl-10 xl:w-[230px] xl:pl-14">
|
||||
Status
|
||||
</TableHead>
|
||||
<TableHead className="hidden w-[110px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
|
||||
Tempo
|
||||
</TableHead>
|
||||
<TableHead className="hidden w-[200px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 xl:table-cell">
|
||||
<TableHead className="hidden w-[180px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 xl:table-cell xl:w-[200px]">
|
||||
Responsável
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
|
||||
<TableHead className="w-[130px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 sm:w-[140px]">
|
||||
Atualizado
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
|
|
@ -217,12 +218,12 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden lg:table-cell pl-8`}>
|
||||
<TableCell className={`${cellClass} hidden lg:table-cell pl-6`}>
|
||||
<span className="text-sm font-semibold text-neutral-800">
|
||||
{ticket.queue ?? "Sem fila"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden md:table-cell pl-8`}>
|
||||
<TableCell className={`${cellClass} hidden md:table-cell pl-6`}>
|
||||
<div className="flex items-center">
|
||||
<span className="sr-only">Canal {channelLabel[ticket.channel]}</span>
|
||||
<span
|
||||
|
|
@ -234,12 +235,12 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden lg:table-cell pl-8`}>
|
||||
<TableCell className={`${cellClass} hidden lg:table-cell pl-6`}>
|
||||
<span className="max-w-[160px] truncate text-sm text-neutral-800" title={((ticket.company ?? null) as { name?: string } | null)?.name ?? "—"}>
|
||||
{((ticket.company ?? null) as { name?: string } | null)?.name ?? "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} hidden md:table-cell pl-1 pr-8`}>
|
||||
<TableCell className={`${cellClass} hidden md:table-cell pl-1 pr-6 lg:pr-8`}>
|
||||
<div
|
||||
className="inline-flex"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
|
|
@ -248,9 +249,9 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={`${cellClass} pl-14`}>
|
||||
<TableCell className={`${cellClass} pl-6 sm:pl-10 xl:pl-14`}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={cn("text-sm font-semibold break-words leading-tight max-w-[140px] sm:max-w-[180px]", statusTone[ticket.status])}>
|
||||
<span className={cn("text-sm font-semibold break-words leading-tight max-w-[140px] sm:max-w-[180px] lg:max-w-[210px]", statusTone[ticket.status])}>
|
||||
{statusLabel[ticket.status]}
|
||||
</span>
|
||||
{ticket.metrics?.timeWaitingMinutes ? (
|
||||
|
|
@ -286,6 +287,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{tickets.length === 0 && (
|
||||
<Empty className="my-6">
|
||||
<EmptyHeader>
|
||||
|
|
|
|||
|
|
@ -94,6 +94,18 @@ const styles = StyleSheet.create({
|
|||
borderWidth: 1,
|
||||
padding: 18,
|
||||
},
|
||||
sectionHeading: {
|
||||
marginBottom: 12,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 12,
|
||||
borderColor: "#E2E8F0",
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
cardGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontFamily: SEMI_FONT,
|
||||
fontSize: 13,
|
||||
|
|
@ -149,6 +161,9 @@ const styles = StyleSheet.create({
|
|||
backgroundColor: "#FFFFFF",
|
||||
gap: 6,
|
||||
},
|
||||
cardSpacing: {
|
||||
marginTop: 8,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
|
|
@ -451,10 +466,13 @@ function TicketPdfDocument({ ticket, logoDataUrl }: { ticket: TicketWithDetails;
|
|||
) : null}
|
||||
|
||||
{comments.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Comentários</Text>
|
||||
{comments.map((comment) => (
|
||||
<View key={comment.id} style={styles.card} wrap={false}>
|
||||
<>
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={{ ...styles.sectionTitle, marginBottom: 0 }}>Comentários</Text>
|
||||
</View>
|
||||
<View style={styles.cardGroup}>
|
||||
{comments.map((comment, index) => (
|
||||
<View key={comment.id} style={[styles.card, index > 0 ? styles.cardSpacing : null]} wrap={false}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View>
|
||||
<Text style={styles.cardTitle}>{comment.author.name}</Text>
|
||||
|
|
@ -477,13 +495,17 @@ function TicketPdfDocument({ ticket, logoDataUrl }: { ticket: TicketWithDetails;
|
|||
</View>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{timeline.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Linha do tempo</Text>
|
||||
{timeline.map((event) => (
|
||||
<View key={event.id} style={[styles.card, styles.timelineCard]} wrap={false}>
|
||||
<>
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={{ ...styles.sectionTitle, marginBottom: 0 }}>Linha do tempo</Text>
|
||||
</View>
|
||||
<View style={styles.cardGroup}>
|
||||
{timeline.map((event, index) => (
|
||||
<View key={event.id} style={[styles.card, styles.timelineCard, index > 0 ? styles.cardSpacing : null]} wrap={false}>
|
||||
<Text style={styles.cardTitle}>{event.label}</Text>
|
||||
<Text style={styles.cardSubtitle}>{formatDateTime(event.createdAt)}</Text>
|
||||
{event.description ? (
|
||||
|
|
@ -492,6 +514,7 @@ function TicketPdfDocument({ ticket, logoDataUrl }: { ticket: TicketWithDetails;
|
|||
</View>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
</Page>
|
||||
</Document>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue