feat: expand admin companies and users modules
This commit is contained in:
parent
a043b1203c
commit
2e3b46a7b5
31 changed files with 5626 additions and 2003 deletions
|
|
@ -1,87 +1,7 @@
|
|||
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"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
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>
|
||||
)
|
||||
export default function AdminClientsRedirect() {
|
||||
redirect("/admin/users")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,17 @@
|
|||
import { AppShell } from "@/components/app-shell"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { requireStaffSession } from "@/lib/auth-server"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { AdminCompaniesManager } from "@/components/admin/companies/admin-companies-manager"
|
||||
import { fetchCompaniesByTenant, normalizeCompany } from "@/server/company-service"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminCompaniesPage() {
|
||||
const companiesRaw = await prisma.company.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
provisioningCode: true,
|
||||
isAvulso: true,
|
||||
contractedHoursPerMonth: true,
|
||||
cnpj: true,
|
||||
domain: true,
|
||||
phone: true,
|
||||
description: true,
|
||||
address: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})
|
||||
const companies = companiesRaw.map((c) => ({
|
||||
id: c.id,
|
||||
tenantId: c.tenantId,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
provisioningCode: c.provisioningCode ?? null,
|
||||
isAvulso: Boolean(c.isAvulso ?? false),
|
||||
contractedHoursPerMonth: c.contractedHoursPerMonth ?? null,
|
||||
cnpj: c.cnpj ?? null,
|
||||
domain: c.domain ?? null,
|
||||
phone: c.phone ?? null,
|
||||
description: c.description ?? null,
|
||||
address: c.address ?? null,
|
||||
}))
|
||||
const session = await requireStaffSession()
|
||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
const companies = (await fetchCompaniesByTenant(tenantId)).map(normalizeCompany)
|
||||
return (
|
||||
<AppShell
|
||||
header={
|
||||
|
|
@ -47,7 +19,7 @@ export default async function AdminCompaniesPage() {
|
|||
}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-7xl px-4 md:px-8 lg:px-10">
|
||||
<AdminCompaniesManager initialCompanies={companies} />
|
||||
<AdminCompaniesManager initialCompanies={companies} tenantId={tenantId} />
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
|||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default function AdminMachinesPage() {
|
||||
export default function AdminMachinesPage({ searchParams }: { searchParams?: { [key: string]: string | string[] | undefined } }) {
|
||||
const company = typeof searchParams?.company === 'string' ? searchParams?.company : undefined
|
||||
return (
|
||||
<AppShell
|
||||
header={
|
||||
|
|
@ -17,7 +18,7 @@ export default function AdminMachinesPage() {
|
|||
}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
|
||||
<AdminMachinesOverview tenantId={DEFAULT_TENANT_ID} />
|
||||
<AdminMachinesOverview tenantId={DEFAULT_TENANT_ID} initialCompanyFilterSlug={company ?? "all"} />
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
|
|
|
|||
98
src/app/admin/users/page.tsx
Normal file
98
src/app/admin/users/page.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { AppShell } from "@/components/app-shell"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { requireStaffSession } from "@/lib/auth-server"
|
||||
import { AdminUsersWorkspace, type AdminAccount } from "@/components/admin/users/admin-users-workspace"
|
||||
import { normalizeCompany } from "@/server/company-service"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
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 accounts: AdminAccount[] = 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 ?? user.email,
|
||||
role: user.role === "MANAGER" ? "MANAGER" : "COLLABORATOR",
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
const companiesRaw = await prisma.company.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
const companies = companiesRaw.map(normalizeCompany)
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={
|
||||
<SiteHeader
|
||||
title="Usuários"
|
||||
lead="Gerencie acessos de gestores/colaboradores e mantenha contatos, unidades e contratos atualizados."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-7xl px-4 pb-12 lg:px-8">
|
||||
<AdminUsersWorkspace initialAccounts={accounts} companies={companies} />
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,156 +1,2 @@
|
|||
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) })
|
||||
}
|
||||
export { runtime } from "../users/route"
|
||||
export { GET, DELETE } from "../users/route"
|
||||
|
|
|
|||
|
|
@ -1,48 +1,99 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { ZodError } from "zod"
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { assertStaffSession } from "@/lib/auth-server"
|
||||
import { isAdmin } from "@/lib/authz"
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"
|
||||
import { removeConvexCompany, syncConvexCompany } from "@/server/companies-sync"
|
||||
import {
|
||||
buildCompanyData,
|
||||
fetchCompanyById,
|
||||
formatZodError,
|
||||
normalizeCompany,
|
||||
sanitizeCompanyInput,
|
||||
} from "@/server/company-service"
|
||||
import type { CompanyFormValues } from "@/lib/schemas/company"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
function mergePayload(base: CompanyFormValues, updates: Record<string, unknown>): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> = { ...base, ...updates }
|
||||
|
||||
if (!("businessHours" in updates)) merged.businessHours = base.businessHours
|
||||
if (!("communicationChannels" in updates)) merged.communicationChannels = base.communicationChannels
|
||||
if (!("clientDomains" in updates)) merged.clientDomains = base.clientDomains
|
||||
if (!("fiscalAddress" in updates)) merged.fiscalAddress = base.fiscalAddress
|
||||
if (!("regulatedEnvironments" in updates)) merged.regulatedEnvironments = base.regulatedEnvironments
|
||||
if (!("contacts" in updates)) merged.contacts = base.contacts
|
||||
if (!("locations" in updates)) merged.locations = base.locations
|
||||
if (!("contracts" in updates)) merged.contracts = base.contracts
|
||||
if (!("sla" in updates)) merged.sla = base.sla
|
||||
if (!("tags" in updates)) merged.tags = base.tags
|
||||
if (!("customFields" in updates)) merged.customFields = base.customFields
|
||||
|
||||
if ("privacyPolicy" in updates) {
|
||||
const incoming = updates.privacyPolicy
|
||||
if (incoming && typeof incoming === "object") {
|
||||
merged.privacyPolicy = {
|
||||
...base.privacyPolicy,
|
||||
...incoming,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
merged.privacyPolicy = base.privacyPolicy
|
||||
}
|
||||
|
||||
merged.tenantId = base.tenantId
|
||||
return merged
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
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 editar empresas" }, { status: 403 })
|
||||
}
|
||||
const { id } = await params
|
||||
const raw = (await request.json()) as Partial<{
|
||||
name: string
|
||||
slug: string
|
||||
cnpj: string | null
|
||||
domain: string | null
|
||||
phone: string | null
|
||||
description: string | null
|
||||
address: string | null
|
||||
isAvulso: boolean
|
||||
contractedHoursPerMonth: number | string | null
|
||||
}>
|
||||
|
||||
const updates: Record<string, unknown> = {}
|
||||
if (typeof raw.name === "string" && raw.name.trim()) updates.name = raw.name.trim()
|
||||
if (typeof raw.slug === "string" && raw.slug.trim()) updates.slug = raw.slug.trim()
|
||||
if ("cnpj" in raw) updates.cnpj = raw.cnpj ?? null
|
||||
if ("domain" in raw) updates.domain = raw.domain ?? null
|
||||
if ("phone" in raw) updates.phone = raw.phone ?? null
|
||||
if ("description" in raw) updates.description = raw.description ?? null
|
||||
if ("address" in raw) updates.address = raw.address ?? null
|
||||
if ("isAvulso" in raw) updates.isAvulso = Boolean(raw.isAvulso)
|
||||
if ("contractedHoursPerMonth" in raw) {
|
||||
const v = raw.contractedHoursPerMonth
|
||||
updates.contractedHoursPerMonth = typeof v === "number" ? v : v ? Number(v) : null
|
||||
}
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const company = await prisma.company.update({ where: { id }, data: updates })
|
||||
const existing = await fetchCompanyById(id)
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "Empresa não encontrada" }, { status: 404 })
|
||||
}
|
||||
|
||||
if (existing.tenantId !== (session.user.tenantId ?? existing.tenantId)) {
|
||||
return NextResponse.json({ error: "Acesso negado" }, { status: 403 })
|
||||
}
|
||||
|
||||
const rawBody = (await request.json()) as Record<string, unknown>
|
||||
const normalized = normalizeCompany(existing)
|
||||
const {
|
||||
id: _ignoreId,
|
||||
provisioningCode: _ignoreCode,
|
||||
createdAt: _createdAt,
|
||||
updatedAt: _updatedAt,
|
||||
...baseForm
|
||||
} = normalized
|
||||
void _ignoreId
|
||||
void _ignoreCode
|
||||
void _createdAt
|
||||
void _updatedAt
|
||||
|
||||
const mergedInput = mergePayload(baseForm, rawBody)
|
||||
const form = sanitizeCompanyInput(mergedInput, existing.tenantId)
|
||||
const createData = buildCompanyData(form, existing.tenantId)
|
||||
const { tenantId: _omitTenant, ...updateData } = createData
|
||||
void _omitTenant
|
||||
|
||||
const company = await prisma.company.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
if (company.provisioningCode) {
|
||||
const synced = await syncConvexCompany({
|
||||
|
|
@ -56,17 +107,23 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
|
|||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ company })
|
||||
return NextResponse.json({ company: normalizeCompany(company) })
|
||||
} catch (error) {
|
||||
console.error("Failed to update company", error)
|
||||
if (error instanceof ZodError) {
|
||||
return NextResponse.json({ error: "Dados inválidos", issues: formatZodError(error) }, { status: 422 })
|
||||
}
|
||||
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
return NextResponse.json({ error: "Já existe uma empresa com este slug." }, { status: 409 })
|
||||
}
|
||||
console.error("Failed to update company", error)
|
||||
return NextResponse.json({ error: "Falha ao atualizar empresa" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
export async function DELETE(
|
||||
_: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await assertStaffSession()
|
||||
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
if (!isAdmin(session.user.role)) {
|
||||
|
|
@ -74,10 +131,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
|
|||
}
|
||||
const { id } = await params
|
||||
|
||||
const company = await prisma.company.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, tenantId: true, name: true, slug: true },
|
||||
})
|
||||
const company = await fetchCompanyById(id)
|
||||
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: "Empresa não encontrada" }, { status: 404 })
|
||||
|
|
|
|||
|
|
@ -1,22 +1,31 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { randomBytes } from "crypto"
|
||||
import { ZodError } from "zod"
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { assertStaffSession } from "@/lib/auth-server"
|
||||
import { isAdmin } from "@/lib/authz"
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"
|
||||
import { syncConvexCompany } from "@/server/companies-sync"
|
||||
import {
|
||||
buildCompanyData,
|
||||
fetchCompaniesByTenant,
|
||||
formatZodError,
|
||||
normalizeCompany,
|
||||
sanitizeCompanyInput,
|
||||
} from "@/server/company-service"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
const DEFAULT_TENANT_ID = "tenant-atlas"
|
||||
|
||||
export async function GET() {
|
||||
const session = await assertStaffSession()
|
||||
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
|
||||
const companies = await prisma.company.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
return NextResponse.json({ companies })
|
||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
const companies = await fetchCompaniesByTenant(tenantId)
|
||||
return NextResponse.json({ companies: companies.map(normalizeCompany) })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
|
@ -26,43 +35,16 @@ export async function POST(request: Request) {
|
|||
return NextResponse.json({ error: "Apenas administradores podem criar empresas" }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = (await request.json()) as Partial<{
|
||||
name: string
|
||||
slug: string
|
||||
isAvulso: boolean
|
||||
contractedHoursPerMonth: number | string | null
|
||||
cnpj: string | null
|
||||
domain: string | null
|
||||
phone: string | null
|
||||
description: string | null
|
||||
address: string | null
|
||||
}>
|
||||
const { name, slug, isAvulso, contractedHoursPerMonth, cnpj, domain, phone, description, address } = body ?? {}
|
||||
if (!name || !slug) {
|
||||
return NextResponse.json({ error: "Nome e slug são obrigatórios" }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const rawBody = await request.json()
|
||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
const form = sanitizeCompanyInput(rawBody, tenantId)
|
||||
const provisioningCode = randomBytes(32).toString("hex")
|
||||
|
||||
const company = await prisma.company.create({
|
||||
data: {
|
||||
tenantId: session.user.tenantId ?? "tenant-atlas",
|
||||
name: String(name),
|
||||
slug: String(slug),
|
||||
...buildCompanyData(form, tenantId),
|
||||
provisioningCode,
|
||||
// Campos opcionais
|
||||
isAvulso: Boolean(isAvulso ?? false),
|
||||
contractedHoursPerMonth:
|
||||
typeof contractedHoursPerMonth === "number"
|
||||
? contractedHoursPerMonth
|
||||
: contractedHoursPerMonth
|
||||
? Number(contractedHoursPerMonth)
|
||||
: null,
|
||||
cnpj: cnpj ? String(cnpj) : null,
|
||||
domain: domain ? String(domain) : null,
|
||||
phone: phone ? String(phone) : null,
|
||||
description: description ? String(description) : null,
|
||||
address: address ? String(address) : null,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -78,16 +60,18 @@ export async function POST(request: Request) {
|
|||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ company })
|
||||
return NextResponse.json({ company: normalizeCompany(company) })
|
||||
} catch (error) {
|
||||
console.error("Failed to create company", error)
|
||||
if (error instanceof ZodError) {
|
||||
return NextResponse.json({ error: "Dados inválidos", issues: formatZodError(error) }, { status: 422 })
|
||||
}
|
||||
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
// Duplicidade de slug por tenant ou provisioningCode único
|
||||
return NextResponse.json(
|
||||
{ error: "Já existe uma empresa com este slug ou código de provisionamento." },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
console.error("Failed to create company", error)
|
||||
return NextResponse.json({ error: "Falha ao criar empresa" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,19 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
import { hashPassword } from "better-auth/crypto"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import type { UserRole } from "@prisma/client"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { assertStaffSession } from "@/lib/auth-server"
|
||||
import { ROLE_OPTIONS, type RoleOption, isAdmin } from "@/lib/authz"
|
||||
import { isAdmin } from "@/lib/authz"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
function normalizeRole(input: string | null | undefined): RoleOption {
|
||||
const role = (input ?? "agent").toLowerCase() as RoleOption
|
||||
return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent"
|
||||
}
|
||||
const ALLOWED_ROLES = ["MANAGER", "COLLABORATOR"] as const
|
||||
|
||||
const USER_ROLE_OPTIONS: ReadonlyArray<UserRole> = ["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]
|
||||
type AllowedRole = (typeof ALLOWED_ROLES)[number]
|
||||
|
||||
function mapToUserRole(role: RoleOption): UserRole {
|
||||
const candidate = role.toUpperCase() as UserRole
|
||||
return USER_ROLE_OPTIONS.includes(candidate) ? candidate : "AGENT"
|
||||
}
|
||||
|
||||
function generatePassword(length = 12) {
|
||||
const bytes = randomBytes(length)
|
||||
return Array.from(bytes)
|
||||
.map((byte) => (byte % 36).toString(36))
|
||||
.join("")
|
||||
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() {
|
||||
|
|
@ -38,111 +22,135 @@ export async function GET() {
|
|||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const users = await prisma.authUser.findMany({
|
||||
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,
|
||||
name: true,
|
||||
role: true,
|
||||
tenantId: 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,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ users })
|
||||
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 POST(request: Request) {
|
||||
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 criar usuários" }, { status: 403 })
|
||||
return NextResponse.json({ error: "Apenas administradores podem excluir usuários." }, { status: 403 })
|
||||
}
|
||||
|
||||
const payload = await request.json().catch(() => null)
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
|
||||
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 usuário selecionado." }, { status: 400 })
|
||||
}
|
||||
|
||||
const emailInput = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : ""
|
||||
const nameInput = typeof payload.name === "string" ? payload.name.trim() : ""
|
||||
const roleInput = typeof payload.role === "string" ? payload.role : undefined
|
||||
const tenantInput = typeof payload.tenantId === "string" ? payload.tenantId.trim() : undefined
|
||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
||||
if (!emailInput || !emailInput.includes("@")) {
|
||||
return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 })
|
||||
}
|
||||
|
||||
const role = normalizeRole(roleInput)
|
||||
const tenantId = tenantInput || session.user.tenantId || DEFAULT_TENANT_ID
|
||||
const userRole = mapToUserRole(role)
|
||||
|
||||
const existing = await prisma.authUser.findUnique({ where: { email: emailInput } })
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Já existe um usuário com este e-mail" }, { status: 409 })
|
||||
}
|
||||
|
||||
const password = generatePassword()
|
||||
const hashedPassword = await hashPassword(password)
|
||||
|
||||
const user = await prisma.authUser.create({
|
||||
data: {
|
||||
email: emailInput,
|
||||
name: nameInput || emailInput,
|
||||
role,
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
tenantId,
|
||||
accounts: {
|
||||
create: {
|
||||
providerId: "credential",
|
||||
accountId: emailInput,
|
||||
password: hashedPassword,
|
||||
},
|
||||
},
|
||||
role: { in: [...ALLOWED_ROLES] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
tenantId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { email: user.email },
|
||||
update: {
|
||||
name: user.name ?? user.email,
|
||||
role: userRole,
|
||||
tenantId,
|
||||
},
|
||||
create: {
|
||||
email: user.email,
|
||||
name: user.name ?? user.email,
|
||||
role: userRole,
|
||||
tenantId,
|
||||
},
|
||||
})
|
||||
|
||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (convexUrl) {
|
||||
try {
|
||||
const convex = new ConvexHttpClient(convexUrl)
|
||||
await convex.mutation(api.users.ensureUser, {
|
||||
tenantId,
|
||||
email: emailInput,
|
||||
name: nameInput || emailInput,
|
||||
avatarUrl: undefined,
|
||||
role: userRole,
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("Falha ao sincronizar usuário no Convex", error)
|
||||
}
|
||||
if (users.length === 0) {
|
||||
return NextResponse.json({ deletedIds: [] })
|
||||
}
|
||||
|
||||
return NextResponse.json({ user, temporaryPassword: password })
|
||||
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) })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,440 +0,0 @@
|
|||
"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 { 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
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"
|
||||
import { TablePagination } from "@/components/ui/table-pagination"
|
||||
|
||||
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 [deleteDialogIds, setDeleteDialogIds] = useState<string[]>([])
|
||||
|
||||
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 deleteTargets = useMemo(
|
||||
() => clients.filter((client) => deleteDialogIds.includes(client.id)),
|
||||
[clients, deleteDialogIds],
|
||||
)
|
||||
|
||||
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({})
|
||||
setDeleteDialogIds([])
|
||||
}
|
||||
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="outline"
|
||||
size="sm"
|
||||
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||
disabled={isPending}
|
||||
onClick={() => setDeleteDialogIds([row.original.id])}
|
||||
>
|
||||
<IconTrash className="mr-2 size-4" /> Remover
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[isPending, setDeleteDialogIds]
|
||||
)
|
||||
|
||||
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)
|
||||
const isBulkDelete = deleteTargets.length > 1
|
||||
const dialogTitle = isBulkDelete ? "Remover clientes selecionados" : "Remover cliente"
|
||||
const dialogDescription = isBulkDelete
|
||||
? "Essa ação remove os clientes selecionados e revoga o acesso ao portal."
|
||||
: "Essa ação remove o cliente escolhido e revoga o acesso ao portal."
|
||||
const previewTargets = deleteTargets.slice(0, 3)
|
||||
const remainingCount = deleteTargets.length - previewTargets.length
|
||||
|
||||
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="outline"
|
||||
size="sm"
|
||||
className="gap-2 border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700 disabled:text-rose-300 disabled:border-rose-100"
|
||||
disabled={selectedRows.length === 0 || isPending}
|
||||
onClick={() => setDeleteDialogIds(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>
|
||||
|
||||
<TablePagination
|
||||
table={table}
|
||||
pageSizeOptions={[10, 20, 30, 40, 50]}
|
||||
rowsPerPageLabel="Itens por página"
|
||||
showSelectedRows
|
||||
selectionLabel={(selected, total) => `${selected} de ${total} selecionados`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={deleteDialogIds.length > 0}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isPending) {
|
||||
setDeleteDialogIds([])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogDescription>{dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{deleteTargets.length > 0 ? (
|
||||
<div className="space-y-3 py-2 text-sm text-neutral-600">
|
||||
<p>
|
||||
Confirme a exclusão de {isBulkDelete ? `${deleteTargets.length} clientes selecionados` : "um cliente"}. O acesso ao portal será revogado imediatamente.
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{previewTargets.map((target) => (
|
||||
<li key={target.id} className="rounded-md bg-slate-100 px-3 py-2 text-sm text-neutral-800">
|
||||
<span className="font-medium">{target.name}</span>
|
||||
<span className="text-neutral-500"> — {target.email}</span>
|
||||
</li>
|
||||
))}
|
||||
{remainingCount > 0 ? (
|
||||
<li className="px-3 py-1 text-xs text-neutral-500">+ {remainingCount} outro{remainingCount === 1 ? "" : "s"}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogIds([])} disabled={isPending}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(deleteDialogIds)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? "Removendo..." : isBulkDelete ? "Excluir clientes" : "Excluir cliente"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -3,17 +3,25 @@
|
|||
import { useMemo } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { MachineDetails, type MachinesQueryItem } from "@/components/admin/machines/admin-machines-overview"
|
||||
import {
|
||||
MachineDetails,
|
||||
normalizeMachineItem,
|
||||
type MachinesQueryItem,
|
||||
} from "@/components/admin/machines/admin-machines-overview"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export function AdminMachineDetailsClient({ tenantId, machineId }: { tenantId: string; machineId: string }) {
|
||||
const queryResult = useQuery(api.machines.listByTenant, { tenantId, includeMetadata: true }) as MachinesQueryItem[] | undefined
|
||||
const isLoading = queryResult === undefined
|
||||
const rawResult = useQuery(api.machines.listByTenant, { tenantId, includeMetadata: true }) as Array<Record<string, unknown>> | undefined
|
||||
const machines: MachinesQueryItem[] | undefined = useMemo(() => {
|
||||
if (!rawResult) return undefined
|
||||
return rawResult.map((item) => normalizeMachineItem(item))
|
||||
}, [rawResult])
|
||||
const isLoading = rawResult === undefined
|
||||
const machine = useMemo(() => {
|
||||
if (!queryResult) return null
|
||||
return queryResult.find((m) => m.id === machineId) ?? null
|
||||
}, [queryResult, machineId])
|
||||
if (!machines) return null
|
||||
return machines.find((m) => m.id === machineId) ?? null
|
||||
}, [machines, machineId])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -224,6 +224,15 @@ type MachineInventory = {
|
|||
collaborator?: { email?: string; name?: string; role?: string }
|
||||
}
|
||||
|
||||
type MachineRemoteAccess = {
|
||||
provider: string | null
|
||||
identifier: string | null
|
||||
url: string | null
|
||||
notes: string | null
|
||||
lastVerifiedAt: number | null
|
||||
metadata: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
function collectInitials(name: string): string {
|
||||
const words = name.split(/\s+/).filter(Boolean)
|
||||
if (words.length === 0) return "?"
|
||||
|
|
@ -267,6 +276,59 @@ function readNumber(record: Record<string, unknown>, ...keys: string[]): number
|
|||
return undefined
|
||||
}
|
||||
|
||||
export function normalizeMachineRemoteAccess(raw: unknown): MachineRemoteAccess | null {
|
||||
if (!raw) return null
|
||||
if (typeof raw === "string") {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return null
|
||||
const isUrl = /^https?:\/\//i.test(trimmed)
|
||||
return {
|
||||
provider: null,
|
||||
identifier: isUrl ? null : trimmed,
|
||||
url: isUrl ? trimmed : null,
|
||||
notes: null,
|
||||
lastVerifiedAt: null,
|
||||
metadata: null,
|
||||
}
|
||||
}
|
||||
const record = toRecord(raw)
|
||||
if (!record) return null
|
||||
const provider = readString(record, "provider", "tool", "vendor", "name") ?? null
|
||||
const identifier = readString(record, "identifier", "code", "id", "accessId") ?? null
|
||||
const url = readString(record, "url", "link", "remoteUrl", "console", "viewer") ?? null
|
||||
const notes = readString(record, "notes", "note", "description", "obs") ?? null
|
||||
const timestampCandidate =
|
||||
readNumber(record, "lastVerifiedAt", "verifiedAt", "checkedAt", "updatedAt") ??
|
||||
parseDateish(record["lastVerifiedAt"] ?? record["verifiedAt"] ?? record["checkedAt"] ?? record["updatedAt"])
|
||||
const lastVerifiedAt = timestampCandidate instanceof Date ? timestampCandidate.getTime() : timestampCandidate ?? null
|
||||
return {
|
||||
provider,
|
||||
identifier,
|
||||
url,
|
||||
notes,
|
||||
lastVerifiedAt,
|
||||
metadata: record,
|
||||
}
|
||||
}
|
||||
|
||||
function formatRemoteAccessMetadataKey(key: string) {
|
||||
return key
|
||||
.replace(/[_.-]+/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
function formatRemoteAccessMetadataValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return ""
|
||||
if (typeof value === "string") return value
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (value instanceof Date) return formatAbsoluteDateTime(value)
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function readText(record: Record<string, unknown>, ...keys: string[]): string | undefined {
|
||||
const stringValue = readString(record, ...keys)
|
||||
if (stringValue) return stringValue
|
||||
|
|
@ -663,15 +725,23 @@ export type MachinesQueryItem = {
|
|||
postureAlerts?: Array<Record<string, unknown>> | null
|
||||
lastPostureAt?: number | null
|
||||
linkedUsers?: Array<{ id: string; email: string; name: string }>
|
||||
remoteAccess: MachineRemoteAccess | null
|
||||
}
|
||||
|
||||
export function normalizeMachineItem(raw: Record<string, unknown>): MachinesQueryItem {
|
||||
const base = raw as MachinesQueryItem
|
||||
return {
|
||||
...base,
|
||||
remoteAccess: normalizeMachineRemoteAccess(raw["remoteAccess"]) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function useMachinesQuery(tenantId: string): MachinesQueryItem[] {
|
||||
return (
|
||||
(useQuery(api.machines.listByTenant, {
|
||||
tenantId,
|
||||
includeMetadata: true,
|
||||
}) ?? []) as MachinesQueryItem[]
|
||||
)
|
||||
const result = useQuery(api.machines.listByTenant, {
|
||||
tenantId,
|
||||
includeMetadata: true,
|
||||
}) as Array<Record<string, unknown>> | undefined
|
||||
return useMemo(() => (result ?? []).map((item) => normalizeMachineItem(item)), [result])
|
||||
}
|
||||
|
||||
const DEFAULT_OFFLINE_THRESHOLD_MS = 10 * 60 * 1000
|
||||
|
|
@ -975,11 +1045,11 @@ function OsIcon({ osName }: { osName?: string | null }) {
|
|||
return <Monitor className="size-4 text-black" />
|
||||
}
|
||||
|
||||
export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
||||
export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "all" }: { tenantId: string; initialCompanyFilterSlug?: string }) {
|
||||
const machines = useMachinesQuery(tenantId)
|
||||
const [q, setQ] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||
const [companyFilterSlug, setCompanyFilterSlug] = useState<string>("all")
|
||||
const [companyFilterSlug, setCompanyFilterSlug] = useState<string>(initialCompanyFilterSlug)
|
||||
const [companySearch, setCompanySearch] = useState<string>("")
|
||||
const [onlyAlerts, setOnlyAlerts] = useState<boolean>(false)
|
||||
const { convexUserId } = useAuth()
|
||||
|
|
@ -1599,6 +1669,53 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
|
||||
const personaLabel = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
|
||||
|
||||
const remoteAccess = machine?.remoteAccess ?? null
|
||||
const remoteAccessMetadataEntries = useMemo(() => {
|
||||
if (!remoteAccess?.metadata) return [] as Array<[string, unknown]>
|
||||
const knownKeys = new Set([
|
||||
"provider",
|
||||
"tool",
|
||||
"vendor",
|
||||
"name",
|
||||
"identifier",
|
||||
"code",
|
||||
"id",
|
||||
"accessId",
|
||||
"url",
|
||||
"link",
|
||||
"remoteUrl",
|
||||
"console",
|
||||
"viewer",
|
||||
"notes",
|
||||
"note",
|
||||
"description",
|
||||
"obs",
|
||||
"lastVerifiedAt",
|
||||
"verifiedAt",
|
||||
"checkedAt",
|
||||
"updatedAt",
|
||||
])
|
||||
return Object.entries(remoteAccess.metadata)
|
||||
.filter(([key, value]) => {
|
||||
if (knownKeys.has(key)) return false
|
||||
if (value === null || value === undefined) return false
|
||||
if (typeof value === "string" && value.trim().length === 0) return false
|
||||
return true
|
||||
})
|
||||
}, [remoteAccess])
|
||||
const remoteAccessLastVerifiedDate = useMemo(() => {
|
||||
if (!remoteAccess?.lastVerifiedAt) return null
|
||||
const date = new Date(remoteAccess.lastVerifiedAt)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}, [remoteAccess?.lastVerifiedAt])
|
||||
const hasRemoteAccess = Boolean(
|
||||
remoteAccess?.identifier ||
|
||||
remoteAccess?.url ||
|
||||
remoteAccess?.notes ||
|
||||
remoteAccess?.provider ||
|
||||
remoteAccessMetadataEntries.length > 0
|
||||
)
|
||||
|
||||
const summaryChips = useMemo(() => {
|
||||
const chips: Array<{ key: string; label: string; value: string; icon: ReactNode; tone?: "warning" | "muted" }> = []
|
||||
const osName = osNameDisplay || "Sistema desconhecido"
|
||||
|
|
@ -1652,8 +1769,18 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
icon: <ShieldCheck className="size-4 text-neutral-500" />,
|
||||
})
|
||||
}
|
||||
if (remoteAccess && (remoteAccess.identifier || remoteAccess.url)) {
|
||||
const value = remoteAccess.identifier ?? remoteAccess.url ?? "—"
|
||||
const label = remoteAccess.provider ? `Acesso (${remoteAccess.provider})` : "Acesso remoto"
|
||||
chips.push({
|
||||
key: "remote-access",
|
||||
label,
|
||||
value,
|
||||
icon: <Key className="size-4 text-neutral-500" />,
|
||||
})
|
||||
}
|
||||
return chips
|
||||
}, [osNameDisplay, machine?.osVersion, machine?.architecture, windowsVersionLabel, windowsBuildLabel, windowsActivationStatus, primaryGpu, collaborator?.email, collaborator?.name, personaLabel, machine?.osName])
|
||||
}, [osNameDisplay, machine?.osVersion, machine?.architecture, windowsVersionLabel, windowsBuildLabel, windowsActivationStatus, primaryGpu, collaborator?.email, collaborator?.name, personaLabel, machine?.osName, remoteAccess])
|
||||
|
||||
const companyName = (() => {
|
||||
if (!companies || !machine?.companySlug) return machine?.companySlug ?? null
|
||||
|
|
@ -1782,6 +1909,17 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const handleCopyRemoteIdentifier = useCallback(async () => {
|
||||
if (!remoteAccess?.identifier) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(remoteAccess.identifier)
|
||||
toast.success("Identificador de acesso remoto copiado.")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível copiar o identificador.")
|
||||
}
|
||||
}, [remoteAccess?.identifier])
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="gap-1">
|
||||
|
|
@ -1861,6 +1999,61 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{hasRemoteAccess ? (
|
||||
<div className="rounded-lg border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-xs sm:text-sm text-slate-700">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-indigo-600">
|
||||
<Key className="size-4" />
|
||||
Acesso remoto
|
||||
{remoteAccess?.provider ? (
|
||||
<Badge variant="outline" className="border-indigo-200 bg-white text-[11px] font-semibold text-indigo-700">
|
||||
{remoteAccess.provider}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{remoteAccess?.identifier ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-slate-900">{remoteAccess.identifier}</span>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-2 px-2" onClick={handleCopyRemoteIdentifier}>
|
||||
<ClipboardCopy className="size-3.5" /> Copiar ID
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{remoteAccess?.url ? (
|
||||
<a
|
||||
href={remoteAccess.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 text-indigo-700 underline-offset-4 hover:underline"
|
||||
>
|
||||
Abrir console remoto
|
||||
</a>
|
||||
) : null}
|
||||
{remoteAccess?.notes ? (
|
||||
<p className="text-[11px] text-slate-600">{remoteAccess.notes}</p>
|
||||
) : null}
|
||||
{remoteAccessLastVerifiedDate ? (
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Atualizado {formatRelativeTime(remoteAccessLastVerifiedDate)}
|
||||
{" "}
|
||||
<span className="text-slate-400">({formatAbsoluteDateTime(remoteAccessLastVerifiedDate)})</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{remoteAccessMetadataEntries.length ? (
|
||||
<div className="mt-3 grid gap-2 text-[11px] text-slate-600 sm:grid-cols-2">
|
||||
{remoteAccessMetadataEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between gap-3 rounded-md border border-indigo-100 bg-white px-2 py-1">
|
||||
<span className="font-semibold text-slate-700">{formatRemoteAccessMetadataKey(key)}</span>
|
||||
<span className="truncate text-right text-slate-600">{formatRemoteAccessMetadataValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
|
|
|
|||
1163
src/components/admin/users/admin-users-workspace.tsx
Normal file
1163
src/components/admin/users/admin-users-workspace.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -23,16 +23,16 @@ export function AppShell({ header, children }: AppShellProps) {
|
|||
<AuthGuard />
|
||||
</Suspense>
|
||||
{isLoading ? (
|
||||
<div className="px-4 pt-4 lg:px-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<Skeleton className="h-9 w-28" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
<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)">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<Skeleton className="h-4 w-52" />
|
||||
<Skeleton className="h-7 w-40" />
|
||||
</div>
|
||||
<Skeleton className="mt-2 h-4 w-72" />
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<Skeleton className="h-9 w-28" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
</header>
|
||||
) : (
|
||||
header
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ const navigation: NavigationGroup[] = [
|
|||
url: "/admin/companies",
|
||||
icon: Building2,
|
||||
requiredRole: "admin",
|
||||
children: [{ title: "Clientes", url: "/admin/clients", icon: Users, requiredRole: "admin" }],
|
||||
children: [{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" }],
|
||||
},
|
||||
{ title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" },
|
||||
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
|
||||
|
|
|
|||
61
src/components/ui/accordion.tsx
Normal file
61
src/components/ui/accordion.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b border-border/60 last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 text-left text-sm font-medium transition-all hover:text-foreground/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
className="h-4 w-4 shrink-0 transition-transform duration-200"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
144
src/components/ui/multi-value-input.tsx
Normal file
144
src/components/ui/multi-value-input.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import * as React from "react"
|
||||
import { IconX } from "@tabler/icons-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type MultiValueInputProps = {
|
||||
values: string[]
|
||||
onChange: (values: string[]) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
maxItems?: number
|
||||
addOnBlur?: boolean
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
validate?: (value: string) => string | null
|
||||
format?: (value: string) => string
|
||||
emptyState?: React.ReactNode
|
||||
}
|
||||
|
||||
export function MultiValueInput({
|
||||
values,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
maxItems,
|
||||
addOnBlur,
|
||||
className,
|
||||
inputClassName,
|
||||
validate,
|
||||
format,
|
||||
emptyState,
|
||||
}: MultiValueInputProps) {
|
||||
const [pending, setPending] = React.useState("")
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const remainingSlots = typeof maxItems === "number" ? Math.max(maxItems - values.length, 0) : undefined
|
||||
const canAdd = remainingSlots === undefined || remainingSlots > 0
|
||||
|
||||
const addValue = React.useCallback(
|
||||
(raw: string) => {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return
|
||||
const formatted = format ? format(trimmed) : trimmed
|
||||
if (values.includes(formatted)) {
|
||||
setPending("")
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
if (validate) {
|
||||
const validation = validate(formatted)
|
||||
if (validation) {
|
||||
setError(validation)
|
||||
return
|
||||
}
|
||||
}
|
||||
setError(null)
|
||||
onChange([...values, formatted])
|
||||
setPending("")
|
||||
},
|
||||
[format, onChange, validate, values]
|
||||
)
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter" || event.key === "," || event.key === "Tab") {
|
||||
if (!canAdd) return
|
||||
event.preventDefault()
|
||||
addValue(pending)
|
||||
}
|
||||
if (event.key === "Backspace" && pending.length === 0 && values.length > 0) {
|
||||
onChange(values.slice(0, -1))
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
if (addOnBlur && pending.trim()) {
|
||||
addValue(pending)
|
||||
}
|
||||
}
|
||||
|
||||
const removeValue = (value: string) => {
|
||||
onChange(values.filter((item) => item !== value))
|
||||
setError(null)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap gap-2 rounded-md border border-input bg-background px-3 py-2 focus-within:ring-2 focus-within:ring-ring",
|
||||
disabled && "opacity-60"
|
||||
)}
|
||||
>
|
||||
{values.map((value) => (
|
||||
<Badge
|
||||
key={value}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium"
|
||||
>
|
||||
<span>{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
onClick={() => removeValue(value)}
|
||||
aria-label={`Remover ${value}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<IconX className="h-3 w-3" strokeWidth={2} />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{canAdd ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={pending}
|
||||
onChange={(event) => {
|
||||
setPending(event.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
placeholder={values.length === 0 ? placeholder : undefined}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"m-0 h-auto min-w-[8rem] flex-1 border-0 bg-transparent px-0 py-1 text-sm shadow-none focus-visible:ring-0",
|
||||
inputClassName
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? <p className="text-xs font-medium text-destructive">{error}</p> : null}
|
||||
{!values.length && emptyState ? <div className="text-xs text-muted-foreground">{emptyState}</div> : null}
|
||||
{typeof remainingSlots === "number" ? (
|
||||
<div className="text-right text-[11px] text-muted-foreground">
|
||||
{remainingSlots} item{remainingSlots === 1 ? "" : "s"} restantes
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/components/ui/scroll-area.tsx
Normal file
33
src/components/ui/scroll-area.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
type ScrollAreaProps = React.ComponentPropsWithoutRef<"div"> & {
|
||||
orientation?: "vertical" | "horizontal" | "both"
|
||||
}
|
||||
|
||||
const orientationClasses: Record<NonNullable<ScrollAreaProps["orientation"]>, string> = {
|
||||
vertical: "overflow-y-auto",
|
||||
horizontal: "overflow-x-auto",
|
||||
both: "overflow-auto",
|
||||
}
|
||||
|
||||
const baseClasses =
|
||||
"relative [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted [&::-webkit-scrollbar-track]:bg-transparent"
|
||||
|
||||
const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
|
||||
({ className, orientation = "vertical", children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={[baseClasses, orientationClasses[orientation], className].filter(Boolean).join(" ")}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ScrollArea.displayName = "ScrollArea"
|
||||
|
||||
export { ScrollArea }
|
||||
83
src/components/ui/time-picker.tsx
Normal file
83
src/components/ui/time-picker.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type TimePickerProps = {
|
||||
value?: string | null
|
||||
onChange?: (value: string) => void
|
||||
className?: string
|
||||
placeholder?: string
|
||||
stepMinutes?: number
|
||||
}
|
||||
|
||||
function pad2(n: number) {
|
||||
return String(n).padStart(2, "0")
|
||||
}
|
||||
|
||||
export function TimePicker({ value, onChange, className, placeholder = "Selecionar horário", stepMinutes = 15 }: TimePickerProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const [hours, minutes] = React.useMemo(() => {
|
||||
if (!value || !/^\d{2}:\d{2}$/.test(value)) return ["", ""]
|
||||
const [h, m] = value.split(":")
|
||||
return [h, m]
|
||||
}, [value])
|
||||
|
||||
const minuteOptions = React.useMemo(() => {
|
||||
const list: string[] = []
|
||||
for (let i = 0; i < 60; i += stepMinutes) list.push(pad2(i))
|
||||
if (!list.includes("00")) list.unshift("00")
|
||||
return list
|
||||
}, [stepMinutes])
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className={cn("w-full justify-between font-normal", className)}>
|
||||
{value ? value : placeholder}
|
||||
<ChevronDownIcon className="ml-2 size-4 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="flex gap-2 p-2">
|
||||
<div className="max-h-56 w-16 overflow-auto rounded-md border">
|
||||
{Array.from({ length: 24 }, (_, h) => pad2(h)).map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
type="button"
|
||||
className={cn(
|
||||
"block w-full px-3 py-1.5 text-left text-sm hover:bg-muted",
|
||||
h === hours && "bg-muted/70 font-semibold"
|
||||
)}
|
||||
onClick={() => onChange?.(`${h}:${minutes || "00"}`)}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-h-56 w-16 overflow-auto rounded-md border">
|
||||
{minuteOptions.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className={cn(
|
||||
"block w-full px-3 py-1.5 text-left text-sm hover:bg-muted",
|
||||
m === minutes && "bg-muted/70 font-semibold"
|
||||
)}
|
||||
onClick={() => onChange?.(`${hours || "00"}:${m}`)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
439
src/lib/schemas/company.ts
Normal file
439
src/lib/schemas/company.ts
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
import { z } from "zod"
|
||||
|
||||
export const BRAZILIAN_UF = [
|
||||
{ value: "AC", label: "Acre" },
|
||||
{ value: "AL", label: "Alagoas" },
|
||||
{ value: "AP", label: "Amapá" },
|
||||
{ value: "AM", label: "Amazonas" },
|
||||
{ value: "BA", label: "Bahia" },
|
||||
{ value: "CE", label: "Ceará" },
|
||||
{ value: "DF", label: "Distrito Federal" },
|
||||
{ value: "ES", label: "Espírito Santo" },
|
||||
{ value: "GO", label: "Goiás" },
|
||||
{ value: "MA", label: "Maranhão" },
|
||||
{ value: "MT", label: "Mato Grosso" },
|
||||
{ value: "MS", label: "Mato Grosso do Sul" },
|
||||
{ value: "MG", label: "Minas Gerais" },
|
||||
{ value: "PA", label: "Pará" },
|
||||
{ value: "PB", label: "Paraíba" },
|
||||
{ value: "PR", label: "Paraná" },
|
||||
{ value: "PE", label: "Pernambuco" },
|
||||
{ value: "PI", label: "Piauí" },
|
||||
{ value: "RJ", label: "Rio de Janeiro" },
|
||||
{ value: "RN", label: "Rio Grande do Norte" },
|
||||
{ value: "RS", label: "Rio Grande do Sul" },
|
||||
{ value: "RO", label: "Rondônia" },
|
||||
{ value: "RR", label: "Roraima" },
|
||||
{ value: "SC", label: "Santa Catarina" },
|
||||
{ value: "SP", label: "São Paulo" },
|
||||
{ value: "SE", label: "Sergipe" },
|
||||
{ value: "TO", label: "Tocantins" },
|
||||
] as const
|
||||
|
||||
export const COMPANY_STATE_REGISTRATION_TYPES = [
|
||||
{ value: "standard", label: "Inscrição estadual" },
|
||||
{ value: "exempt", label: "Isento" },
|
||||
{ value: "simples", label: "Simples Nacional" },
|
||||
] as const
|
||||
|
||||
export const COMPANY_CONTACT_ROLES = [
|
||||
{ value: "financeiro", label: "Financeiro" },
|
||||
{ value: "decisor", label: "Decisor" },
|
||||
{ value: "ti", label: "TI" },
|
||||
{ value: "juridico", label: "Jurídico" },
|
||||
{ value: "compras", label: "Compras" },
|
||||
{ value: "usuario_chave", label: "Usuário-chave" },
|
||||
{ value: "outro", label: "Outro" },
|
||||
] as const
|
||||
|
||||
export const COMPANY_CONTACT_PREFERENCES = [
|
||||
{ value: "email", label: "E-mail" },
|
||||
{ value: "phone", label: "Telefone" },
|
||||
{ value: "whatsapp", label: "WhatsApp" },
|
||||
{ value: "business_hours", label: "Horário comercial" },
|
||||
] as const
|
||||
|
||||
export const COMPANY_LOCATION_TYPES = [
|
||||
{ value: "matrix", label: "Matriz" },
|
||||
{ value: "branch", label: "Filial" },
|
||||
{ value: "data_center", label: "Data Center" },
|
||||
{ value: "home_office", label: "Home Office" },
|
||||
{ value: "other", label: "Outro" },
|
||||
] as const
|
||||
|
||||
export const COMPANY_CONTRACT_TYPES = [
|
||||
{ value: "monthly", label: "Mensalidade" },
|
||||
{ value: "time_bank", label: "Banco de horas" },
|
||||
{ value: "per_ticket", label: "Por chamado" },
|
||||
{ value: "project", label: "Projetos" },
|
||||
] as const
|
||||
|
||||
export const COMPANY_CONTRACT_SCOPES = [
|
||||
"suporte_m365",
|
||||
"endpoints",
|
||||
"rede",
|
||||
"servidores",
|
||||
"backup",
|
||||
"email",
|
||||
"impressoras",
|
||||
"seguranca",
|
||||
] as const
|
||||
|
||||
export const COMPANY_CRITICALITY_LEVELS = [
|
||||
{ value: "low", label: "Baixa" },
|
||||
{ value: "medium", label: "Média" },
|
||||
{ value: "high", label: "Alta" },
|
||||
] as const
|
||||
|
||||
export const COMPANY_REGULATION_OPTIONS = [
|
||||
{ value: "lgpd_critical", label: "LGPD crítico" },
|
||||
{ value: "finance", label: "Financeiro" },
|
||||
{ value: "health", label: "Saúde" },
|
||||
{ value: "public", label: "Setor público" },
|
||||
{ value: "education", label: "Educação" },
|
||||
{ value: "custom", label: "Outro" },
|
||||
] as const
|
||||
|
||||
export const SLA_SEVERITY_LEVELS = ["P1", "P2", "P3", "P4"] as const
|
||||
|
||||
const daySchema = z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])
|
||||
const ufSchema = z.enum(BRAZILIAN_UF.map((item) => item.value) as [string, ...string[]])
|
||||
const stateRegistrationTypeSchema = z.enum(
|
||||
COMPANY_STATE_REGISTRATION_TYPES.map((item) => item.value) as [string, ...string[]]
|
||||
)
|
||||
const contactRoleSchema = z.enum(
|
||||
COMPANY_CONTACT_ROLES.map((item) => item.value) as [string, ...string[]]
|
||||
)
|
||||
const contactPreferenceSchema = z.enum(
|
||||
COMPANY_CONTACT_PREFERENCES.map((item) => item.value) as [string, ...string[]]
|
||||
)
|
||||
const locationTypeSchema = z.enum(
|
||||
COMPANY_LOCATION_TYPES.map((item) => item.value) as [string, ...string[]]
|
||||
)
|
||||
const contractTypeSchema = z.enum(
|
||||
COMPANY_CONTRACT_TYPES.map((item) => item.value) as [string, ...string[]]
|
||||
)
|
||||
const contractScopeSchema = z.enum(COMPANY_CONTRACT_SCOPES)
|
||||
const criticalitySchema = z.enum(
|
||||
COMPANY_CRITICALITY_LEVELS.map((item) => item.value) as [string, ...string[]]
|
||||
)
|
||||
const regulationSchema = z.enum(
|
||||
COMPANY_REGULATION_OPTIONS.map((item) => item.value) as [string, ...string[]]
|
||||
)
|
||||
const severitySchema = z.enum(SLA_SEVERITY_LEVELS)
|
||||
|
||||
const phoneRegex = /^[0-9()+\s-]{8,20}$/
|
||||
const timeRegex = /^\d{2}:\d{2}$/
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/
|
||||
const cnpjDigitsRegex = /^\d{14}$/
|
||||
const cepRegex = /^\d{5}-?\d{3}$/
|
||||
const domainRegex =
|
||||
/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/i
|
||||
|
||||
const optionalString = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => (value.length === 0 ? null : value))
|
||||
.nullable()
|
||||
.optional()
|
||||
|
||||
const monetarySchema = z
|
||||
.union([z.number(), z.string()])
|
||||
.transform((value) => {
|
||||
if (typeof value === "number") return value
|
||||
const normalized = value.replace(/\./g, "").replace(",", ".")
|
||||
const parsed = Number(normalized)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
})
|
||||
.nullable()
|
||||
|
||||
const servicePeriodSchema = z.object({
|
||||
days: z.array(daySchema).min(1, "Informe ao menos um dia"),
|
||||
start: z
|
||||
.string()
|
||||
.regex(timeRegex, "Use o formato HH:MM"),
|
||||
end: z
|
||||
.string()
|
||||
.regex(timeRegex, "Use o formato HH:MM"),
|
||||
})
|
||||
|
||||
export const businessHoursSchema = z
|
||||
.object({
|
||||
mode: z.enum(["business", "twentyfour", "custom"]).default("business"),
|
||||
timezone: z.string().min(3).default("America/Sao_Paulo"),
|
||||
periods: z.array(servicePeriodSchema).default([]),
|
||||
})
|
||||
.refine(
|
||||
(value) => {
|
||||
if (value.mode === "twentyfour") return true
|
||||
return value.periods.length > 0
|
||||
},
|
||||
{ message: "Defina pelo menos um período", path: ["periods"] }
|
||||
)
|
||||
|
||||
const addressSchema = z
|
||||
.object({
|
||||
street: z.string().min(3, "Informe a rua"),
|
||||
number: z.string().min(1, "Informe o número"),
|
||||
complement: optionalString,
|
||||
district: z.string().min(2, "Informe o bairro"),
|
||||
city: z.string().min(2, "Informe a cidade"),
|
||||
state: ufSchema,
|
||||
zip: z
|
||||
.string()
|
||||
.regex(cepRegex, "CEP inválido"),
|
||||
})
|
||||
.partial({
|
||||
complement: true,
|
||||
})
|
||||
|
||||
const contactSchema = z.object({
|
||||
id: z.string(),
|
||||
fullName: z.string().min(3, "Nome obrigatório"),
|
||||
email: z.string().email("E-mail inválido"),
|
||||
phone: z
|
||||
.string()
|
||||
.regex(phoneRegex, "Telefone inválido")
|
||||
.nullable()
|
||||
.optional(),
|
||||
whatsapp: z
|
||||
.string()
|
||||
.regex(phoneRegex, "WhatsApp inválido")
|
||||
.nullable()
|
||||
.optional(),
|
||||
role: contactRoleSchema,
|
||||
title: z.string().trim().nullable().optional(),
|
||||
preference: z.array(contactPreferenceSchema).default([]),
|
||||
canAuthorizeTickets: z.boolean().default(false),
|
||||
canApproveCosts: z.boolean().default(false),
|
||||
lgpdConsent: z.boolean().default(true),
|
||||
notes: z.string().trim().nullable().optional(),
|
||||
})
|
||||
|
||||
const locationSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(2, "Informe o nome da unidade"),
|
||||
type: locationTypeSchema,
|
||||
address: addressSchema.nullish(),
|
||||
responsibleContactId: z.string().nullable().optional(),
|
||||
serviceWindow: z
|
||||
.object({
|
||||
mode: z.enum(["inherit", "custom"]).default("inherit"),
|
||||
periods: z.array(servicePeriodSchema).default([]),
|
||||
})
|
||||
.refine(
|
||||
(value) => (value.mode === "inherit" ? true : value.periods.length > 0),
|
||||
{ message: "Defina períodos personalizados", path: ["periods"] }
|
||||
),
|
||||
notes: z.string().trim().nullable().optional(),
|
||||
})
|
||||
|
||||
const contractSchema = z.object({
|
||||
id: z.string(),
|
||||
contractType: contractTypeSchema,
|
||||
planSku: z.string().trim().nullable().optional(),
|
||||
startDate: z
|
||||
.string()
|
||||
.regex(dateRegex, "Formato AAAA-MM-DD")
|
||||
.nullable()
|
||||
.optional(),
|
||||
endDate: z
|
||||
.string()
|
||||
.regex(dateRegex, "Formato AAAA-MM-DD")
|
||||
.nullable()
|
||||
.optional(),
|
||||
renewalDate: z
|
||||
.string()
|
||||
.regex(dateRegex, "Formato AAAA-MM-DD")
|
||||
.nullable()
|
||||
.optional(),
|
||||
scope: z.array(contractScopeSchema).default([]),
|
||||
price: monetarySchema,
|
||||
costCenter: z.string().trim().nullable().optional(),
|
||||
criticality: criticalitySchema.default("medium"),
|
||||
notes: z.string().trim().nullable().optional(),
|
||||
})
|
||||
|
||||
const severityEntrySchema = z.object({
|
||||
level: severitySchema,
|
||||
responseMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(60),
|
||||
resolutionMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(240),
|
||||
})
|
||||
|
||||
const slaSchema = z.object({
|
||||
calendar: z.enum(["24x7", "business", "custom"]).default("business"),
|
||||
validChannels: z.array(z.string().min(3)).default([]),
|
||||
holidays: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.regex(dateRegex, "Formato AAAA-MM-DD")
|
||||
)
|
||||
.default([]),
|
||||
severities: z
|
||||
.array(severityEntrySchema)
|
||||
.default([
|
||||
{ level: "P1", responseMinutes: 30, resolutionMinutes: 240 },
|
||||
{ level: "P2", responseMinutes: 60, resolutionMinutes: 480 },
|
||||
{ level: "P3", responseMinutes: 120, resolutionMinutes: 1440 },
|
||||
{ level: "P4", responseMinutes: 240, resolutionMinutes: 2880 },
|
||||
]),
|
||||
serviceWindow: z
|
||||
.object({
|
||||
timezone: z.string().default("America/Sao_Paulo"),
|
||||
periods: z.array(servicePeriodSchema).default([]),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const communicationChannelsSchema = z.object({
|
||||
supportEmails: z.array(z.string().email()).default([]),
|
||||
billingEmails: z.array(z.string().email()).default([]),
|
||||
whatsappNumbers: z.array(z.string().regex(phoneRegex, "Telefone inválido")).default([]),
|
||||
phones: z.array(z.string().regex(phoneRegex, "Telefone inválido")).default([]),
|
||||
portals: z.array(z.string().url("URL inválida")).default([]),
|
||||
})
|
||||
|
||||
const privacyPolicySchema = z.object({
|
||||
accepted: z.boolean().default(false),
|
||||
reference: z.string().url("URL inválida").nullable().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
const customFieldSchema = z.object({
|
||||
id: z.string(),
|
||||
key: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
type: z.enum(["text", "number", "boolean", "date", "url"]).default("text"),
|
||||
value: z.union([z.string(), z.number(), z.boolean(), z.null()]).nullable(),
|
||||
})
|
||||
|
||||
const domainSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.regex(domainRegex, "Domínio inválido")
|
||||
|
||||
export const companyFormSchema = z.object({
|
||||
tenantId: z.string(),
|
||||
name: z.string().min(2, "Nome obrigatório"),
|
||||
slug: z
|
||||
.string()
|
||||
.min(2, "Informe um apelido")
|
||||
.regex(/^[a-z0-9-]+$/, "Use apenas letras minúsculas, números ou hífen"),
|
||||
legalName: z.string().trim().nullable().optional(),
|
||||
tradeName: z.string().trim().nullable().optional(),
|
||||
cnpj: z
|
||||
.string()
|
||||
.regex(cnpjDigitsRegex, "CNPJ deve ter 14 dígitos")
|
||||
.nullable()
|
||||
.optional(),
|
||||
stateRegistration: z.string().trim().nullable().optional(),
|
||||
stateRegistrationType: stateRegistrationTypeSchema.nullish(),
|
||||
primaryCnae: z.string().trim().nullable().optional(),
|
||||
description: z.string().trim().nullable().optional(),
|
||||
domain: domainSchema.nullable().optional(),
|
||||
phone: z
|
||||
.string()
|
||||
.regex(phoneRegex, "Telefone inválido")
|
||||
.nullable()
|
||||
.optional(),
|
||||
address: z.string().trim().nullable().optional(),
|
||||
contractedHoursPerMonth: z
|
||||
.number()
|
||||
.min(0)
|
||||
.nullable()
|
||||
.optional(),
|
||||
businessHours: businessHoursSchema.nullish(),
|
||||
communicationChannels: communicationChannelsSchema.default({
|
||||
supportEmails: [],
|
||||
billingEmails: [],
|
||||
whatsappNumbers: [],
|
||||
phones: [],
|
||||
portals: [],
|
||||
}),
|
||||
supportEmail: z.string().email().nullable().optional(),
|
||||
billingEmail: z.string().email().nullable().optional(),
|
||||
contactPreferences: z
|
||||
.object({
|
||||
defaultChannel: contactPreferenceSchema.nullable().optional(),
|
||||
escalationNotes: z.string().trim().nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
clientDomains: z.array(domainSchema).default([]),
|
||||
fiscalAddress: addressSchema.nullish(),
|
||||
hasBranches: z.boolean().default(false),
|
||||
regulatedEnvironments: z.array(regulationSchema).default([]),
|
||||
privacyPolicy: privacyPolicySchema.default({
|
||||
accepted: false,
|
||||
reference: null,
|
||||
}),
|
||||
contacts: z.array(contactSchema).default([]),
|
||||
locations: z.array(locationSchema).default([]),
|
||||
contracts: z.array(contractSchema).default([]),
|
||||
sla: slaSchema.nullish(),
|
||||
tags: z.array(z.string().trim().min(1)).default([]),
|
||||
customFields: z.array(customFieldSchema).default([]),
|
||||
notes: z.string().trim().nullable().optional(),
|
||||
isAvulso: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export type CompanyFormValues = z.infer<typeof companyFormSchema>
|
||||
export type CompanyContact = z.infer<typeof contactSchema>
|
||||
export type CompanyLocation = z.infer<typeof locationSchema>
|
||||
export type CompanyContract = z.infer<typeof contractSchema>
|
||||
export type CompanySla = z.infer<typeof slaSchema>
|
||||
export type CompanyBusinessHours = z.infer<typeof businessHoursSchema>
|
||||
export type CompanyCommunicationChannels = z.infer<typeof communicationChannelsSchema>
|
||||
export type CompanyStateRegistrationTypeOption =
|
||||
(typeof COMPANY_STATE_REGISTRATION_TYPES)[number]["value"]
|
||||
|
||||
export const defaultBusinessHours: CompanyBusinessHours = {
|
||||
mode: "business",
|
||||
timezone: "America/Sao_Paulo",
|
||||
periods: [
|
||||
{
|
||||
days: ["mon", "tue", "wed", "thu", "fri"],
|
||||
start: "09:00",
|
||||
end: "18:00",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const defaultSla: CompanySla = {
|
||||
calendar: "business",
|
||||
validChannels: [],
|
||||
holidays: [],
|
||||
severities: [
|
||||
{ level: "P1", responseMinutes: 30, resolutionMinutes: 240 },
|
||||
{ level: "P2", responseMinutes: 60, resolutionMinutes: 480 },
|
||||
{ level: "P3", responseMinutes: 120, resolutionMinutes: 1440 },
|
||||
{ level: "P4", responseMinutes: 240, resolutionMinutes: 2880 },
|
||||
],
|
||||
serviceWindow: {
|
||||
timezone: "America/Sao_Paulo",
|
||||
periods: [
|
||||
{
|
||||
days: ["mon", "tue", "wed", "thu", "fri"],
|
||||
start: "09:00",
|
||||
end: "18:00",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export type CompanyFormPayload = CompanyFormValues
|
||||
|
||||
export const companyInputSchema = companyFormSchema.extend({
|
||||
tenantId: companyFormSchema.shape.tenantId.optional(),
|
||||
})
|
||||
|
||||
export type CompanyInputPayload = z.infer<typeof companyInputSchema>
|
||||
489
src/server/company-service.ts
Normal file
489
src/server/company-service.ts
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
import { Prisma, type Company, type CompanyStateRegistrationType } from "@prisma/client"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { ZodError } from "zod"
|
||||
|
||||
import {
|
||||
companyFormSchema,
|
||||
companyInputSchema,
|
||||
type CompanyCommunicationChannels,
|
||||
type CompanyFormValues,
|
||||
type CompanyStateRegistrationTypeOption,
|
||||
} from "@/lib/schemas/company"
|
||||
|
||||
export type NormalizedCompany = CompanyFormValues & {
|
||||
id: string
|
||||
provisioningCode: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
function slugify(value?: string | null): string {
|
||||
if (!value) return ""
|
||||
const ascii = value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
const collapsed = ascii.trim().replace(/[_\s]+/g, "-")
|
||||
const sanitized = collapsed.replace(/-+/g, "-").toLowerCase()
|
||||
return sanitized.replace(/^-+|-+$/g, "")
|
||||
}
|
||||
|
||||
function ensureSlugValue(
|
||||
inputSlug: string | null | undefined,
|
||||
fallbackName: string | null | undefined,
|
||||
fallbackId?: string
|
||||
): string {
|
||||
const slugFromInput = slugify(inputSlug)
|
||||
if (slugFromInput) return slugFromInput
|
||||
const slugFromName = slugify(fallbackName)
|
||||
if (slugFromName) return slugFromName
|
||||
if (fallbackId) {
|
||||
const slugFromId = slugify(fallbackId)
|
||||
if (slugFromId) return slugFromId
|
||||
return fallbackId.toLowerCase()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const STATE_REGISTRATION_TYPE_TO_PRISMA: Record<
|
||||
CompanyStateRegistrationTypeOption,
|
||||
CompanyStateRegistrationType
|
||||
> = {
|
||||
standard: "STANDARD",
|
||||
exempt: "EXEMPT",
|
||||
simples: "SIMPLES",
|
||||
}
|
||||
|
||||
const STATE_REGISTRATION_TYPE_FROM_PRISMA: Record<
|
||||
CompanyStateRegistrationType,
|
||||
CompanyStateRegistrationTypeOption
|
||||
> = {
|
||||
STANDARD: "standard",
|
||||
EXEMPT: "exempt",
|
||||
SIMPLES: "simples",
|
||||
}
|
||||
|
||||
export function formatZodError(error: ZodError) {
|
||||
return error.issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
message: issue.message,
|
||||
}))
|
||||
}
|
||||
|
||||
function sanitizeDomain(value?: string | null) {
|
||||
if (!value) return null
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
return trimmed.length === 0 ? null : trimmed
|
||||
}
|
||||
|
||||
function sanitizePhone(value?: string | null) {
|
||||
if (!value) return null
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length === 0 ? null : trimmed
|
||||
}
|
||||
|
||||
function normalizeChannels(
|
||||
channels?: Partial<CompanyCommunicationChannels> | null
|
||||
): CompanyCommunicationChannels {
|
||||
const ensure = (values?: string[]) =>
|
||||
Array.from(new Set((values ?? []).map((value) => value.trim()).filter(Boolean)))
|
||||
return {
|
||||
supportEmails: ensure(channels?.supportEmails).map((email) => email.toLowerCase()),
|
||||
billingEmails: ensure(channels?.billingEmails).map((email) => email.toLowerCase()),
|
||||
whatsappNumbers: ensure(channels?.whatsappNumbers),
|
||||
phones: ensure(channels?.phones),
|
||||
portals: ensure(channels?.portals),
|
||||
}
|
||||
}
|
||||
|
||||
function mergeChannelsWithPrimary(
|
||||
payload: CompanyFormValues,
|
||||
base?: CompanyCommunicationChannels
|
||||
): CompanyCommunicationChannels {
|
||||
const channels = normalizeChannels(base ?? payload.communicationChannels)
|
||||
const supportEmails = new Set(channels.supportEmails)
|
||||
const billingEmails = new Set(channels.billingEmails)
|
||||
const phones = new Set(channels.phones)
|
||||
|
||||
if (payload.supportEmail) supportEmails.add(payload.supportEmail.toLowerCase())
|
||||
if (payload.billingEmail) billingEmails.add(payload.billingEmail.toLowerCase())
|
||||
if (payload.phone) phones.add(payload.phone)
|
||||
|
||||
return {
|
||||
supportEmails: Array.from(supportEmails),
|
||||
billingEmails: Array.from(billingEmails),
|
||||
whatsappNumbers: channels.whatsappNumbers,
|
||||
phones: Array.from(phones),
|
||||
portals: channels.portals,
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeCompanyInput(input: unknown, tenantId: string): CompanyFormValues {
|
||||
const parsed = companyInputSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
throw parsed.error
|
||||
}
|
||||
|
||||
const raw = parsed.data
|
||||
const normalizedName = raw.name?.trim() ?? ""
|
||||
const normalizedSlug = ensureSlugValue(raw.slug, normalizedName, raw.slug ?? raw.name ?? undefined)
|
||||
const cnpjDigits =
|
||||
typeof raw.cnpj === "string" ? raw.cnpj.replace(/\D/g, "").slice(0, 14) : null
|
||||
const normalizedContacts = (raw.contacts ?? []).map((contact) => ({
|
||||
...contact,
|
||||
email: contact.email?.trim().toLowerCase(),
|
||||
phone: sanitizePhone(contact.phone),
|
||||
whatsapp: sanitizePhone(contact.whatsapp),
|
||||
preference: Array.from(new Set(contact.preference ?? [])),
|
||||
}))
|
||||
|
||||
const normalizedLocations = (raw.locations ?? []).map((location) => ({
|
||||
...location,
|
||||
responsibleContactId: location.responsibleContactId ?? null,
|
||||
serviceWindow: location.serviceWindow ?? { mode: "inherit", periods: [] },
|
||||
}))
|
||||
|
||||
const normalizedContracts = (raw.contracts ?? []).map((contract) => ({
|
||||
...contract,
|
||||
scope: Array.from(new Set(contract.scope ?? [])),
|
||||
}))
|
||||
|
||||
const normalizedTags = Array.from(
|
||||
new Set((raw.tags ?? []).map((tag) => tag.trim()).filter(Boolean))
|
||||
)
|
||||
|
||||
const normalizedCustomFields = (raw.customFields ?? []).map((field) => ({
|
||||
...field,
|
||||
label: field.label.trim(),
|
||||
key: field.key.trim(),
|
||||
}))
|
||||
|
||||
const normalized: CompanyFormValues = companyFormSchema.parse({
|
||||
...raw,
|
||||
tenantId,
|
||||
name: normalizedName,
|
||||
slug: normalizedSlug,
|
||||
legalName: raw.legalName?.trim() ?? null,
|
||||
tradeName: raw.tradeName?.trim() ?? null,
|
||||
cnpj: cnpjDigits && cnpjDigits.length === 14 ? cnpjDigits : null,
|
||||
stateRegistration: raw.stateRegistration?.trim() ?? null,
|
||||
primaryCnae: raw.primaryCnae?.trim() ?? null,
|
||||
description: raw.description?.trim() ?? null,
|
||||
domain: sanitizeDomain(raw.domain),
|
||||
phone: sanitizePhone(raw.phone),
|
||||
address: raw.address?.trim() ?? null,
|
||||
communicationChannels: normalizeChannels(raw.communicationChannels),
|
||||
supportEmail: raw.supportEmail?.trim().toLowerCase() ?? null,
|
||||
billingEmail: raw.billingEmail?.trim().toLowerCase() ?? null,
|
||||
clientDomains: Array.from(
|
||||
new Set((raw.clientDomains ?? []).map((domain) => domain.trim().toLowerCase()).filter(Boolean))
|
||||
),
|
||||
fiscalAddress: raw.fiscalAddress ?? null,
|
||||
regulatedEnvironments: Array.from(new Set(raw.regulatedEnvironments ?? [])),
|
||||
contacts: normalizedContacts,
|
||||
locations: normalizedLocations,
|
||||
contracts: normalizedContracts,
|
||||
businessHours: raw.businessHours ?? null,
|
||||
sla: raw.sla ?? null,
|
||||
tags: normalizedTags,
|
||||
customFields: normalizedCustomFields,
|
||||
notes: raw.notes?.trim() ?? null,
|
||||
privacyPolicy: raw.privacyPolicy
|
||||
? {
|
||||
accepted: raw.privacyPolicy.accepted ?? false,
|
||||
reference: raw.privacyPolicy.reference ?? null,
|
||||
metadata: raw.privacyPolicy.metadata,
|
||||
}
|
||||
: {
|
||||
accepted: false,
|
||||
reference: null,
|
||||
},
|
||||
})
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function buildCompanyData(
|
||||
payload: CompanyFormValues,
|
||||
tenantId: string
|
||||
): Omit<Prisma.CompanyCreateInput, "provisioningCode"> {
|
||||
const stateRegistrationType = payload.stateRegistrationType
|
||||
? STATE_REGISTRATION_TYPE_TO_PRISMA[payload.stateRegistrationType as CompanyStateRegistrationTypeOption]
|
||||
: null
|
||||
|
||||
const communicationChannels = mergeChannelsWithPrimary(payload)
|
||||
const privacyPolicyMetadata = payload.privacyPolicy?.metadata ?? null
|
||||
|
||||
return {
|
||||
tenantId,
|
||||
name: payload.name.trim(),
|
||||
slug: payload.slug.trim(),
|
||||
isAvulso: payload.isAvulso ?? false,
|
||||
contractedHoursPerMonth: payload.contractedHoursPerMonth ?? null,
|
||||
cnpj: payload.cnpj ?? null,
|
||||
domain: payload.domain ?? null,
|
||||
phone: payload.phone ?? null,
|
||||
description: payload.description ?? null,
|
||||
address: payload.address ?? null,
|
||||
legalName: payload.legalName ?? null,
|
||||
tradeName: payload.tradeName ?? null,
|
||||
stateRegistration: payload.stateRegistration ?? null,
|
||||
stateRegistrationType,
|
||||
primaryCnae: payload.primaryCnae ?? null,
|
||||
timezone: payload.businessHours?.timezone ?? null,
|
||||
businessHours: payload.businessHours ?? Prisma.JsonNull,
|
||||
supportEmail: payload.supportEmail ?? null,
|
||||
billingEmail: payload.billingEmail ?? null,
|
||||
contactPreferences:
|
||||
payload.contactPreferences || payload.supportEmail || payload.billingEmail
|
||||
? ({
|
||||
...payload.contactPreferences,
|
||||
supportEmail: payload.supportEmail ?? null,
|
||||
billingEmail: payload.billingEmail ?? null,
|
||||
} satisfies Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
clientDomains: payload.clientDomains,
|
||||
communicationChannels,
|
||||
fiscalAddress: payload.fiscalAddress ?? Prisma.JsonNull,
|
||||
hasBranches: payload.hasBranches ?? false,
|
||||
regulatedEnvironments: payload.regulatedEnvironments,
|
||||
privacyPolicyAccepted: payload.privacyPolicy?.accepted ?? false,
|
||||
privacyPolicyReference: payload.privacyPolicy?.reference ?? null,
|
||||
privacyPolicyMetadata: privacyPolicyMetadata
|
||||
? (privacyPolicyMetadata as Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
contacts: payload.contacts,
|
||||
locations: payload.locations,
|
||||
contracts: payload.contracts,
|
||||
sla: payload.sla ?? Prisma.JsonNull,
|
||||
tags: payload.tags,
|
||||
customFields: payload.customFields,
|
||||
notes: payload.notes ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCompany(company: Company): NormalizedCompany {
|
||||
const communicationChannels = normalizeChannels(
|
||||
company.communicationChannels as CompanyCommunicationChannels | null | undefined
|
||||
)
|
||||
const normalizedName = (company.name ?? "").trim()
|
||||
const normalizedSlug = ensureSlugValue(company.slug, normalizedName || company.name, company.id)
|
||||
const base: CompanyFormValues = {
|
||||
tenantId: company.tenantId,
|
||||
name: normalizedName,
|
||||
slug: normalizedSlug,
|
||||
legalName: company.legalName,
|
||||
tradeName: company.tradeName,
|
||||
cnpj: company.cnpj,
|
||||
stateRegistration: company.stateRegistration,
|
||||
stateRegistrationType: company.stateRegistrationType
|
||||
? STATE_REGISTRATION_TYPE_FROM_PRISMA[company.stateRegistrationType]
|
||||
: undefined,
|
||||
primaryCnae: company.primaryCnae,
|
||||
description: company.description,
|
||||
domain: company.domain,
|
||||
phone: company.phone,
|
||||
address: company.address,
|
||||
contractedHoursPerMonth: company.contractedHoursPerMonth,
|
||||
businessHours: (company.businessHours as CompanyFormValues["businessHours"]) ?? null,
|
||||
communicationChannels,
|
||||
supportEmail: company.supportEmail,
|
||||
billingEmail: company.billingEmail,
|
||||
contactPreferences: (company.contactPreferences as CompanyFormValues["contactPreferences"]) ?? undefined,
|
||||
clientDomains: (company.clientDomains as string[] | null) ?? [],
|
||||
fiscalAddress: (company.fiscalAddress as CompanyFormValues["fiscalAddress"]) ?? null,
|
||||
hasBranches: Boolean(company.hasBranches),
|
||||
regulatedEnvironments: (company.regulatedEnvironments as string[] | null) ?? [],
|
||||
privacyPolicy: {
|
||||
accepted: Boolean(company.privacyPolicyAccepted),
|
||||
reference: company.privacyPolicyReference ?? null,
|
||||
metadata: company.privacyPolicyMetadata
|
||||
? (company.privacyPolicyMetadata as Record<string, unknown>)
|
||||
: undefined,
|
||||
},
|
||||
contacts: (company.contacts as CompanyFormValues["contacts"]) ?? [],
|
||||
locations: (company.locations as CompanyFormValues["locations"]) ?? [],
|
||||
contracts: (company.contracts as CompanyFormValues["contracts"]) ?? [],
|
||||
sla: (company.sla as CompanyFormValues["sla"]) ?? null,
|
||||
tags: (company.tags as string[] | null) ?? [],
|
||||
customFields: (company.customFields as CompanyFormValues["customFields"]) ?? [],
|
||||
notes: company.notes ?? null,
|
||||
isAvulso: Boolean(company.isAvulso),
|
||||
}
|
||||
|
||||
const payload = companyFormSchema.parse({
|
||||
...base,
|
||||
communicationChannels: mergeChannelsWithPrimary(base, communicationChannels),
|
||||
})
|
||||
|
||||
return {
|
||||
...payload,
|
||||
id: company.id,
|
||||
provisioningCode: company.provisioningCode ?? null,
|
||||
createdAt: company.createdAt.toISOString(),
|
||||
updatedAt: company.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
type RawCompanyRow = {
|
||||
id: string
|
||||
tenantId: string
|
||||
name: string
|
||||
slug: string
|
||||
provisioningCode: string | null
|
||||
isAvulso: number | boolean | null
|
||||
contractedHoursPerMonth: number | null
|
||||
cnpj: string | null
|
||||
domain: string | null
|
||||
phone: string | null
|
||||
description: string | null
|
||||
address: string | null
|
||||
legalName: string | null
|
||||
tradeName: string | null
|
||||
stateRegistration: string | null
|
||||
stateRegistrationType: string | null
|
||||
primaryCnae: string | null
|
||||
timezone: string | null
|
||||
businessHours: string | null
|
||||
supportEmail: string | null
|
||||
billingEmail: string | null
|
||||
contactPreferences: string | null
|
||||
clientDomains: string | null
|
||||
communicationChannels: string | null
|
||||
fiscalAddress: string | null
|
||||
hasBranches: number | null
|
||||
regulatedEnvironments: string | null
|
||||
privacyPolicyAccepted: number | null
|
||||
privacyPolicyReference: string | null
|
||||
privacyPolicyMetadata: string | null
|
||||
contacts: string | null
|
||||
locations: string | null
|
||||
contracts: string | null
|
||||
sla: string | null
|
||||
tags: string | null
|
||||
customFields: string | null
|
||||
notes: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
function parseJsonValue(value: string | null): Prisma.JsonValue | null {
|
||||
if (value === null || value === undefined) return null
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || trimmed.toLowerCase() === "null") return null
|
||||
try {
|
||||
return JSON.parse(trimmed) as Prisma.JsonValue
|
||||
} catch (error) {
|
||||
console.warn("[company-service] Invalid JSON detected; coercing to null.", { value, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mapRawRowToCompany(row: RawCompanyRow): Company {
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenantId,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
provisioningCode: row.provisioningCode ?? "",
|
||||
isAvulso: Boolean(row.isAvulso),
|
||||
contractedHoursPerMonth: row.contractedHoursPerMonth,
|
||||
cnpj: row.cnpj,
|
||||
domain: row.domain,
|
||||
phone: row.phone,
|
||||
description: row.description,
|
||||
address: row.address,
|
||||
legalName: row.legalName,
|
||||
tradeName: row.tradeName,
|
||||
stateRegistration: row.stateRegistration,
|
||||
stateRegistrationType: row.stateRegistrationType
|
||||
? (row.stateRegistrationType as CompanyStateRegistrationType)
|
||||
: null,
|
||||
primaryCnae: row.primaryCnae,
|
||||
timezone: row.timezone,
|
||||
businessHours: parseJsonValue(row.businessHours),
|
||||
supportEmail: row.supportEmail,
|
||||
billingEmail: row.billingEmail,
|
||||
contactPreferences: parseJsonValue(row.contactPreferences),
|
||||
clientDomains: parseJsonValue(row.clientDomains),
|
||||
communicationChannels: parseJsonValue(row.communicationChannels),
|
||||
fiscalAddress: parseJsonValue(row.fiscalAddress),
|
||||
hasBranches: Boolean(row.hasBranches),
|
||||
regulatedEnvironments: parseJsonValue(row.regulatedEnvironments),
|
||||
privacyPolicyAccepted: Boolean(row.privacyPolicyAccepted),
|
||||
privacyPolicyReference: row.privacyPolicyReference,
|
||||
privacyPolicyMetadata: parseJsonValue(row.privacyPolicyMetadata),
|
||||
contacts: parseJsonValue(row.contacts),
|
||||
locations: parseJsonValue(row.locations),
|
||||
contracts: parseJsonValue(row.contracts),
|
||||
sla: parseJsonValue(row.sla),
|
||||
tags: parseJsonValue(row.tags),
|
||||
customFields: parseJsonValue(row.customFields),
|
||||
notes: row.notes,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
const COMPANY_BASE_SELECT = Prisma.sql`
|
||||
SELECT
|
||||
id,
|
||||
tenantId,
|
||||
name,
|
||||
slug,
|
||||
provisioningCode,
|
||||
isAvulso,
|
||||
contractedHoursPerMonth,
|
||||
cnpj,
|
||||
domain,
|
||||
phone,
|
||||
description,
|
||||
address,
|
||||
legalName,
|
||||
tradeName,
|
||||
stateRegistration,
|
||||
stateRegistrationType,
|
||||
primaryCnae,
|
||||
timezone,
|
||||
CAST(businessHours AS TEXT) AS businessHours,
|
||||
supportEmail,
|
||||
billingEmail,
|
||||
CAST(contactPreferences AS TEXT) AS contactPreferences,
|
||||
CAST(clientDomains AS TEXT) AS clientDomains,
|
||||
CAST(communicationChannels AS TEXT) AS communicationChannels,
|
||||
CAST(fiscalAddress AS TEXT) AS fiscalAddress,
|
||||
hasBranches,
|
||||
CAST(regulatedEnvironments AS TEXT) AS regulatedEnvironments,
|
||||
privacyPolicyAccepted,
|
||||
privacyPolicyReference,
|
||||
CAST(privacyPolicyMetadata AS TEXT) AS privacyPolicyMetadata,
|
||||
CAST(contacts AS TEXT) AS contacts,
|
||||
CAST(locations AS TEXT) AS locations,
|
||||
CAST(contracts AS TEXT) AS contracts,
|
||||
CAST(sla AS TEXT) AS sla,
|
||||
CAST(tags AS TEXT) AS tags,
|
||||
CAST(customFields AS TEXT) AS customFields,
|
||||
notes,
|
||||
createdAt,
|
||||
updatedAt
|
||||
FROM "Company"
|
||||
`
|
||||
|
||||
export async function fetchCompaniesByTenant(tenantId: string): Promise<Company[]> {
|
||||
const rows = await prisma.$queryRaw<RawCompanyRow[]>(Prisma.sql`
|
||||
${COMPANY_BASE_SELECT}
|
||||
WHERE tenantId = ${tenantId}
|
||||
ORDER BY name ASC
|
||||
`)
|
||||
return rows.map(mapRawRowToCompany)
|
||||
}
|
||||
|
||||
export async function fetchCompanyById(id: string): Promise<Company | null> {
|
||||
const rows = await prisma.$queryRaw<RawCompanyRow[]>(Prisma.sql`
|
||||
${COMPANY_BASE_SELECT}
|
||||
WHERE id = ${id}
|
||||
LIMIT 1
|
||||
`)
|
||||
const row = rows[0]
|
||||
return row ? mapRawRowToCompany(row) : null
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue