feat: ajustar board de tickets
This commit is contained in:
parent
e9a8bd6b9b
commit
d23987eda8
7 changed files with 434 additions and 429 deletions
|
|
@ -4,7 +4,7 @@ import { prisma } from "@/lib/prisma"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { requireStaffSession } from "@/lib/auth-server"
|
import { requireStaffSession } from "@/lib/auth-server"
|
||||||
import { AdminUsersWorkspace, type AdminAccount } from "@/components/admin/users/admin-users-workspace"
|
import { AdminUsersWorkspace, type AdminAccount } from "@/components/admin/users/admin-users-workspace"
|
||||||
import { normalizeCompany } from "@/server/company-service"
|
import { fetchCompaniesByTenant, normalizeCompany } from "@/server/company-service"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
@ -79,10 +79,7 @@ export default async function AdminUsersPage() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const companiesRaw = await prisma.company.findMany({
|
const companiesRaw = await fetchCompaniesByTenant(tenantId)
|
||||||
where: { tenantId },
|
|
||||||
orderBy: { name: "asc" },
|
|
||||||
})
|
|
||||||
const companies = companiesRaw.map(normalizeCompany)
|
const companies = companiesRaw.map(normalizeCompany)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { prisma } from "@/lib/prisma"
|
||||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||||
import { ConvexConfigurationError } from "@/server/convex-client"
|
import { ConvexConfigurationError } from "@/server/convex-client"
|
||||||
import { syncConvexCompany } from "@/server/companies-sync"
|
import { syncConvexCompany } from "@/server/companies-sync"
|
||||||
|
import { safeCompanyFindMany } from "@/server/company-service"
|
||||||
|
|
||||||
export const runtime = "nodejs"
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
|
@ -75,7 +76,7 @@ export async function GET(request: Request) {
|
||||||
...(orFilters.length > 0 ? { OR: orFilters } : {}),
|
...(orFilters.length > 0 ? { OR: orFilters } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const companies = await prisma.company.findMany({
|
const companies = await safeCompanyFindMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
take: 20,
|
take: 20,
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,8 @@ export function TicketsPageClient() {
|
||||||
<SiteHeader
|
<SiteHeader
|
||||||
title="Tickets"
|
title="Tickets"
|
||||||
lead="Visão consolidada de filas e SLAs"
|
lead="Visão consolidada de filas e SLAs"
|
||||||
primaryAction={<NewTicketDialog />}
|
primaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>}
|
||||||
secondaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>}
|
secondaryAction={<NewTicketDialog />}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,12 @@ export function PrioritySelect({
|
||||||
ticketId,
|
ticketId,
|
||||||
value,
|
value,
|
||||||
className,
|
className,
|
||||||
|
badgeClassName,
|
||||||
}: {
|
}: {
|
||||||
ticketId: string
|
ticketId: string
|
||||||
value: TicketPriority
|
value: TicketPriority
|
||||||
className?: string
|
className?: string
|
||||||
|
badgeClassName?: string
|
||||||
}) {
|
}) {
|
||||||
const updatePriority = useMutation(api.tickets.updatePriority)
|
const updatePriority = useMutation(api.tickets.updatePriority)
|
||||||
const [priority, setPriority] = useState<TicketPriority>(value)
|
const [priority, setPriority] = useState<TicketPriority>(value)
|
||||||
|
|
@ -69,7 +71,9 @@ export function PrioritySelect({
|
||||||
>
|
>
|
||||||
<SelectTrigger className={cn(headerTriggerClass, className)} aria-label="Atualizar prioridade">
|
<SelectTrigger className={cn(headerTriggerClass, className)} aria-label="Atualizar prioridade">
|
||||||
<SelectValue asChild>
|
<SelectValue asChild>
|
||||||
<Badge className={cn(priorityBadgeClass, priorityStyles[priority]?.badgeClass)}>
|
<Badge
|
||||||
|
className={cn(priorityBadgeClass, priorityStyles[priority]?.badgeClass, badgeClassName)}
|
||||||
|
>
|
||||||
<PriorityIcon value={priority} />
|
<PriorityIcon value={priority} />
|
||||||
{priorityStyles[priority]?.label ?? priority}
|
{priorityStyles[priority]?.label ?? priority}
|
||||||
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
|
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import type { Ticket } from "@/lib/schemas/ticket"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
|
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
|
import { PrioritySelect } from "@/components/tickets/priority-select"
|
||||||
import { getTicketStatusChipClass, getTicketStatusLabel } from "@/lib/ticket-status-style"
|
import { getTicketStatusChipClass, getTicketStatusLabel } from "@/lib/ticket-status-style"
|
||||||
|
|
||||||
type TicketsBoardProps = {
|
type TicketsBoardProps = {
|
||||||
|
|
@ -57,6 +57,17 @@ function formatUpdated(date: Date | number | string, now: number) {
|
||||||
return formatDistanceStrict(timestamp, now, { addSuffix: true, locale: ptBR })
|
return formatDistanceStrict(timestamp, now, { addSuffix: true, locale: ptBR })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatQueueLabel(queue?: string | null) {
|
||||||
|
if (!queue) {
|
||||||
|
return { label: "Sem fila", title: "Sem fila" }
|
||||||
|
}
|
||||||
|
const normalized = queue.trim().toLowerCase()
|
||||||
|
if (normalized.startsWith("laboratorio")) {
|
||||||
|
return { label: "Lab", title: queue }
|
||||||
|
}
|
||||||
|
return { label: queue, title: queue }
|
||||||
|
}
|
||||||
|
|
||||||
export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
|
export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
|
||||||
const [now, setNow] = useState(() => Date.now())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
|
|
@ -103,73 +114,118 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{tickets.map((ticket) => {
|
{tickets.map((ticket) => {
|
||||||
const isEntering = enteringIds?.has(ticket.id)
|
const isEntering = enteringIds?.has(ticket.id)
|
||||||
|
const queueDisplay = formatQueueLabel(ticket.queue)
|
||||||
|
const updatedTimestamp = getTimestamp(ticket.updatedAt)
|
||||||
|
const updatedAbsoluteLabel = updatedTimestamp
|
||||||
|
? new Date(updatedTimestamp).toLocaleString("pt-BR", {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
})
|
||||||
|
: "—"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={ticket.id}
|
key={ticket.id}
|
||||||
href={`/tickets/${ticket.id}`}
|
href={`/tickets/${ticket.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group block h-full rounded-3xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300",
|
"group flex h-full flex-col rounded-3xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300",
|
||||||
isEntering ? "recent-ticket-enter" : ""
|
isEntering ? "recent-ticket-enter" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex w-full items-center justify-between gap-4">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-slate-200 bg-slate-100 px-3.5 py-1.5 text-xs font-semibold text-neutral-700"
|
className="rounded-full border-slate-200 bg-white px-4 py-2 text-sm font-semibold tracking-tight text-neutral-700"
|
||||||
>
|
>
|
||||||
#{ticket.reference}
|
#{ticket.reference}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<span className="text-right text-sm font-semibold text-neutral-900" title={queueDisplay.title}>
|
||||||
|
{queueDisplay.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-center justify-between gap-4">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition",
|
"inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition",
|
||||||
getTicketStatusChipClass(ticket.status),
|
getTicketStatusChipClass(ticket.status),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{getTicketStatusLabel(ticket.status)}
|
{getTicketStatusLabel(ticket.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<div
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
className="relative z-[1] flex justify-end"
|
||||||
{formatUpdated(ticket.updatedAt, now)}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
</span>
|
onClick={(event) => event.stopPropagation()}
|
||||||
</div>
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
<h3 className="mt-5 line-clamp-2 text-lg font-semibold text-neutral-900">
|
>
|
||||||
{ticket.subject || "Sem assunto"}
|
<PrioritySelect
|
||||||
</h3>
|
className="min-w-[7rem]"
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-neutral-600">
|
badgeClassName="h-9 px-4 shadow-sm"
|
||||||
<span className="font-medium text-neutral-500">Fila:</span>
|
ticketId={ticket.id}
|
||||||
<span className="rounded-full bg-slate-100 px-3.5 py-0.5 text-xs font-medium text-neutral-700">
|
value={ticket.priority}
|
||||||
{ticket.queue ?? "Sem fila"}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium text-neutral-500">Prioridade:</span>
|
|
||||||
<TicketPriorityPill
|
|
||||||
priority={ticket.priority}
|
|
||||||
className="h-7 gap-1.5 px-3.5 text-xs shadow-sm"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<dl className="mt-6 space-y-2.5 text-sm text-neutral-600">
|
</div>
|
||||||
<div className="flex items-start justify-between gap-4">
|
</div>
|
||||||
<dt className="font-medium text-neutral-500">Empresa</dt>
|
|
||||||
<dd className="truncate text-right text-neutral-700">
|
<div className="mt-4 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-center shadow-sm">
|
||||||
|
<h3 className="line-clamp-2 text-lg font-semibold text-neutral-900">
|
||||||
|
{ticket.subject || "Sem assunto"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-1 flex-col gap-5 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
|
<dl className="grid grid-cols-1 gap-4 text-sm text-neutral-600 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||||
|
Empresa
|
||||||
|
</dt>
|
||||||
|
<dd className="text-neutral-700">
|
||||||
{ticket.company?.name ?? "Sem empresa"}
|
{ticket.company?.name ?? "Sem empresa"}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="font-medium text-neutral-500">Responsável</dt>
|
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||||
<dd className="truncate text-right text-neutral-700">
|
Responsável
|
||||||
|
</dt>
|
||||||
|
<dd className="text-neutral-700">
|
||||||
{ticket.assignee?.name ?? "Sem responsável"}
|
{ticket.assignee?.name ?? "Sem responsável"}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="font-medium text-neutral-500">Solicitante</dt>
|
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||||
<dd className="truncate text-right text-neutral-700">
|
Solicitante
|
||||||
|
</dt>
|
||||||
|
<dd className="text-neutral-700">
|
||||||
{ticket.requester?.name ?? ticket.requester?.email ?? "—"}
|
{ticket.requester?.name ?? ticket.requester?.email ?? "—"}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||||
|
Criado em
|
||||||
|
</dt>
|
||||||
|
<dd className="text-neutral-700">
|
||||||
|
{(() => {
|
||||||
|
const createdTimestamp = getTimestamp(ticket.createdAt)
|
||||||
|
return createdTimestamp
|
||||||
|
? new Date(createdTimestamp).toLocaleDateString("pt-BR")
|
||||||
|
: "—"
|
||||||
|
})()}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-center border-t border-slate-200 pt-4 text-sm text-neutral-600 text-center">
|
||||||
|
<span className="text-neutral-700">
|
||||||
|
Categoria:{" "}
|
||||||
|
<span className="font-semibold text-neutral-900">{ticket.category?.name ?? "Sem categoria"}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,99 @@ const STATE_REGISTRATION_TYPE_FROM_PRISMA: Record<
|
||||||
SIMPLES: "simples",
|
SIMPLES: "simples",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMPANY_LEGACY_SELECT = {
|
||||||
|
id: true,
|
||||||
|
tenantId: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
isAvulso: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
} satisfies Prisma.CompanySelect
|
||||||
|
|
||||||
|
type LegacyCompanyRow = Prisma.CompanyGetPayload<{ select: typeof COMPANY_LEGACY_SELECT }>
|
||||||
|
|
||||||
|
function isMissingProvisioningCodeColumn(error: unknown): boolean {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2022") {
|
||||||
|
const meta = error.meta as Record<string, unknown> | undefined
|
||||||
|
const metaColumn =
|
||||||
|
typeof meta?.column === "string"
|
||||||
|
? meta.column
|
||||||
|
: typeof meta?.column_name === "string"
|
||||||
|
? meta.column_name
|
||||||
|
: typeof meta?.model === "string"
|
||||||
|
? meta.model
|
||||||
|
: null
|
||||||
|
if (metaColumn && metaColumn.toLowerCase().includes("provisioningcode")) return true
|
||||||
|
if (error.message.toLowerCase().includes("provisioningcode")) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function safeCompanyFindMany(args: Prisma.CompanyFindManyArgs): Promise<Company[]> {
|
||||||
|
try {
|
||||||
|
return await prisma.company.findMany(args)
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMissingProvisioningCodeColumn(error)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.select || args.include) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyRows = (await prisma.company.findMany({
|
||||||
|
...args,
|
||||||
|
select: COMPANY_LEGACY_SELECT,
|
||||||
|
})) as LegacyCompanyRow[]
|
||||||
|
|
||||||
|
return legacyRows.map(
|
||||||
|
(row) =>
|
||||||
|
({
|
||||||
|
id: row.id,
|
||||||
|
tenantId: row.tenantId,
|
||||||
|
name: row.name,
|
||||||
|
slug: row.slug,
|
||||||
|
provisioningCode: "",
|
||||||
|
isAvulso: Boolean(row.isAvulso),
|
||||||
|
contractedHoursPerMonth: null,
|
||||||
|
cnpj: null,
|
||||||
|
domain: null,
|
||||||
|
phone: null,
|
||||||
|
description: null,
|
||||||
|
address: null,
|
||||||
|
legalName: null,
|
||||||
|
tradeName: null,
|
||||||
|
stateRegistration: null,
|
||||||
|
stateRegistrationType: null,
|
||||||
|
primaryCnae: null,
|
||||||
|
timezone: null,
|
||||||
|
businessHours: Prisma.JsonNull,
|
||||||
|
supportEmail: null,
|
||||||
|
billingEmail: null,
|
||||||
|
contactPreferences: Prisma.JsonNull,
|
||||||
|
clientDomains: Prisma.JsonNull,
|
||||||
|
communicationChannels: Prisma.JsonNull,
|
||||||
|
fiscalAddress: Prisma.JsonNull,
|
||||||
|
hasBranches: false,
|
||||||
|
regulatedEnvironments: Prisma.JsonNull,
|
||||||
|
privacyPolicyAccepted: false,
|
||||||
|
privacyPolicyReference: null,
|
||||||
|
privacyPolicyMetadata: Prisma.JsonNull,
|
||||||
|
contacts: Prisma.JsonNull,
|
||||||
|
locations: Prisma.JsonNull,
|
||||||
|
contracts: Prisma.JsonNull,
|
||||||
|
sla: Prisma.JsonNull,
|
||||||
|
tags: Prisma.JsonNull,
|
||||||
|
customFields: Prisma.JsonNull,
|
||||||
|
notes: null,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
}) as Company
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function formatZodError(error: ZodError) {
|
export function formatZodError(error: ZodError) {
|
||||||
return error.issues.map((issue) => ({
|
return error.issues.map((issue) => ({
|
||||||
path: issue.path.join("."),
|
path: issue.path.join("."),
|
||||||
|
|
@ -328,171 +421,25 @@ export function normalizeCompany(company: Company): NormalizedCompany {
|
||||||
return {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
id: company.id,
|
id: company.id,
|
||||||
provisioningCode: company.provisioningCode ?? null,
|
provisioningCode: company.provisioningCode && company.provisioningCode.trim().length > 0
|
||||||
|
? company.provisioningCode
|
||||||
|
: null,
|
||||||
createdAt: company.createdAt.toISOString(),
|
createdAt: company.createdAt.toISOString(),
|
||||||
updatedAt: company.updatedAt.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 DbCompanyStateRegistrationType)
|
|
||||||
: 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[]> {
|
export async function fetchCompaniesByTenant(tenantId: string): Promise<Company[]> {
|
||||||
const rows = await prisma.$queryRaw<RawCompanyRow[]>(Prisma.sql`
|
return safeCompanyFindMany({
|
||||||
${COMPANY_BASE_SELECT}
|
where: { tenantId },
|
||||||
WHERE tenantId = ${tenantId}
|
orderBy: { name: "asc" },
|
||||||
ORDER BY name ASC
|
})
|
||||||
`)
|
|
||||||
return rows.map(mapRawRowToCompany)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCompanyById(id: string): Promise<Company | null> {
|
export async function fetchCompanyById(id: string): Promise<Company | null> {
|
||||||
const rows = await prisma.$queryRaw<RawCompanyRow[]>(Prisma.sql`
|
const rows = await safeCompanyFindMany({
|
||||||
${COMPANY_BASE_SELECT}
|
where: { id },
|
||||||
WHERE id = ${id}
|
take: 1,
|
||||||
LIMIT 1
|
})
|
||||||
`)
|
return rows[0] ?? null
|
||||||
const row = rows[0]
|
|
||||||
return row ? mapRawRowToCompany(row) : null
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue