feat: cadastro manual de acesso remoto e ajustes de horas

This commit is contained in:
Esdras Renan 2025-10-24 23:52:58 -03:00
parent 8e3cbc7a9a
commit f3a7045691
16 changed files with 1549 additions and 207 deletions

View file

@ -1,44 +1,72 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { MinusIcon, PlusIcon } from 'lucide-react'
import { Button, Group, Input, Label, NumberField } from 'react-aria-components'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const clamp = (value: number, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER) =>
Math.min(Math.max(value, min), max)
const InputWithEndButtonsDemo = () => {
const [value, setValue] = useState<number>(1024)
const minValue = 0
const formattedValue = useMemo(() => (Number.isFinite(value) ? value.toString() : ''), [value])
const handleManualChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const digitsOnly = event.target.value.replace(/\D/g, '')
if (digitsOnly.length === 0) {
setValue(minValue)
return
}
const next = Number.parseInt(digitsOnly, 10)
setValue(clamp(next, minValue))
},
[minValue],
)
const handleIncrement = useCallback(() => setValue((current) => clamp(current + 1, minValue)), [minValue])
const handleDecrement = useCallback(() => setValue((current) => clamp(current - 1, minValue)), [minValue])
return (
<NumberField defaultValue={1024} minValue={0} className='w-full max-w-xs space-y-2'>
<Label className='flex items-center gap-2 text-sm leading-none font-medium select-none'>
<div className='w-full max-w-xs space-y-2'>
<Label className='flex items-center gap-2 text-sm font-medium leading-none text-muted-foreground'>
Input with end buttons
</Label>
<Group className='dark:bg-input/30 border-input data-focus-within:border-ring data-focus-within:ring-ring/50 data-focus-within:has-aria-invalid:ring-destructive/20 dark:data-focus-within:has-aria-invalid:ring-destructive/40 data-focus-within:has-aria-invalid:border-destructive relative inline-flex h-9 w-full min-w-0 items-center overflow-hidden rounded-md border bg-transparent text-base whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 data-focus-within:ring-[3px] md:text-sm'>
<Input className='selection:bg-primary selection:text-primary-foreground w-full grow px-3 py-2 text-center tabular-nums outline-none' />
<div className='dark:bg-input/30 relative inline-flex h-9 w-full min-w-0 items-center overflow-hidden rounded-md border border-input bg-transparent text-base shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/40 md:text-sm'>
<Input
className='selection:bg-primary selection:text-primary-foreground w-full grow px-3 py-2 text-center tabular-nums outline-none'
inputMode='numeric'
pattern='[0-9]*'
value={formattedValue}
onChange={handleManualChange}
/>
<Button
slot='decrement'
className='border-input bg-background text-muted-foreground hover:bg-accent hover:text-foreground -me-px flex aspect-square h-[inherit] items-center justify-center border text-sm transition-[color,box-shadow] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50'
type='button'
variant='ghost'
className='border-input text-muted-foreground hover:bg-accent hover:text-foreground -me-px flex aspect-square h-[inherit] items-center justify-center rounded-none border-l text-sm transition-[color,box-shadow]'
onClick={handleDecrement}
disabled={value <= minValue}
>
<MinusIcon className='size-4' />
<span className='sr-only'>Decrement</span>
</Button>
<Button
slot='increment'
className='border-input bg-background text-muted-foreground hover:bg-accent hover:text-foreground -me-px flex aspect-square h-[inherit] items-center justify-center rounded-r-md border text-sm transition-[color,box-shadow] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50'
type='button'
variant='ghost'
className='border-input text-muted-foreground hover:bg-accent hover:text-foreground flex aspect-square h-[inherit] items-center justify-center rounded-none border-l text-sm transition-[color,box-shadow]'
onClick={handleIncrement}
>
<PlusIcon className='size-4' />
<span className='sr-only'>Increment</span>
</Button>
</Group>
<p className='text-muted-foreground text-xs'>
Built with{' '}
<a
className='hover:text-foreground underline'
href='https://react-spectrum.adobe.com/react-aria/NumberField.html'
target='_blank'
rel='noopener noreferrer'
>
React Aria
</a>
</p>
</NumberField>
</div>
<p className='text-xs text-muted-foreground'>Demonstração simples de input numérico com botões de incremento.</p>
</div>
)
}

View file

@ -1321,6 +1321,86 @@ export const toggleActive = mutation({
},
})
export const updateRemoteAccess = mutation({
args: {
machineId: v.id("machines"),
actorId: v.id("users"),
provider: v.optional(v.string()),
identifier: v.optional(v.string()),
url: v.optional(v.string()),
notes: v.optional(v.string()),
clear: v.optional(v.boolean()),
},
handler: async (ctx, { machineId, actorId, provider, identifier, url, notes, clear }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
}
const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== machine.tenantId) {
throw new ConvexError("Acesso negado ao tenant da máquina")
}
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
throw new ConvexError("Somente administradores e agentes podem ajustar o acesso remoto.")
}
if (clear) {
await ctx.db.patch(machineId, { remoteAccess: null, updatedAt: Date.now() })
return { remoteAccess: null }
}
const trimmedProvider = (provider ?? "").trim()
const trimmedIdentifier = (identifier ?? "").trim()
if (!trimmedProvider || !trimmedIdentifier) {
throw new ConvexError("Informe provedor e identificador do acesso remoto.")
}
let normalizedUrl: string | null = null
if (url) {
const trimmedUrl = url.trim()
if (trimmedUrl) {
if (!/^https?:\/\//i.test(trimmedUrl)) {
throw new ConvexError("Informe uma URL válida iniciando com http:// ou https://.")
}
try {
new URL(trimmedUrl)
} catch {
throw new ConvexError("Informe uma URL válida para o acesso remoto.")
}
normalizedUrl = trimmedUrl
}
}
const cleanedNotes = notes?.trim() ? notes.trim() : null
const lastVerifiedAt = Date.now()
const remoteAccess = {
provider: trimmedProvider,
identifier: trimmedIdentifier,
url: normalizedUrl,
notes: cleanedNotes,
lastVerifiedAt,
metadata: {
provider: trimmedProvider,
identifier: trimmedIdentifier,
url: normalizedUrl,
notes: cleanedNotes,
lastVerifiedAt,
},
}
await ctx.db.patch(machineId, {
remoteAccess,
updatedAt: Date.now(),
})
return { remoteAccess }
},
})
export const remove = mutation({
args: {
machineId: v.id("machines"),

View file

@ -182,6 +182,29 @@ function normalizeStatus(status: string | null | undefined): TicketStatusNormali
return normalized ?? "PENDING";
}
function formatWorkDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) {
return "0m";
}
const totalMinutes = Math.round(ms / 60000);
const hours = Math.floor(totalMinutes / 60);
const minutes = Math.abs(totalMinutes % 60);
const parts: string[] = [];
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (parts.length === 0) {
return "0m";
}
return parts.join(" ");
}
function formatWorkDelta(deltaMs: number): string {
if (deltaMs === 0) return "0m";
const sign = deltaMs > 0 ? "+" : "-";
const absolute = formatWorkDuration(Math.abs(deltaMs));
return `${sign}${absolute}`;
}
type AgentWorkTotals = {
agentId: Id<"users">;
agentName: string | null;
@ -2048,6 +2071,126 @@ export const pauseWork = mutation({
},
})
export const adjustWorkSummary = mutation({
args: {
ticketId: v.id("tickets"),
actorId: v.id("users"),
internalWorkedMs: v.number(),
externalWorkedMs: v.number(),
reason: v.string(),
},
handler: async (ctx, { ticketId, actorId, internalWorkedMs, externalWorkedMs, reason }) => {
const ticket = await ctx.db.get(ticketId)
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
const normalizedRole = (viewer.role ?? "").toUpperCase()
if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
throw new ConvexError("Somente administradores e agentes podem ajustar as horas de um chamado.")
}
if (ticketDoc.activeSessionId) {
throw new ConvexError("Pause o atendimento antes de ajustar as horas do chamado.")
}
const trimmedReason = reason.trim()
if (trimmedReason.length < 5) {
throw new ConvexError("Informe um motivo com pelo menos 5 caracteres.")
}
if (trimmedReason.length > 1000) {
throw new ConvexError("Motivo muito longo (máx. 1000 caracteres).")
}
const previousInternal = Math.max(0, ticketDoc.internalWorkedMs ?? 0)
const previousExternal = Math.max(0, ticketDoc.externalWorkedMs ?? 0)
const previousTotal = Math.max(0, ticketDoc.totalWorkedMs ?? previousInternal + previousExternal)
const nextInternal = Math.max(0, Math.round(internalWorkedMs))
const nextExternal = Math.max(0, Math.round(externalWorkedMs))
const nextTotal = nextInternal + nextExternal
const deltaInternal = nextInternal - previousInternal
const deltaExternal = nextExternal - previousExternal
const deltaTotal = nextTotal - previousTotal
const now = Date.now()
await ctx.db.patch(ticketId, {
internalWorkedMs: nextInternal,
externalWorkedMs: nextExternal,
totalWorkedMs: nextTotal,
updatedAt: now,
})
await ctx.db.insert("ticketEvents", {
ticketId,
type: "WORK_ADJUSTED",
payload: {
actorId,
actorName: viewer.user.name,
actorAvatar: viewer.user.avatarUrl,
previousInternalMs: previousInternal,
previousExternalMs: previousExternal,
previousTotalMs: previousTotal,
nextInternalMs: nextInternal,
nextExternalMs: nextExternal,
nextTotalMs: nextTotal,
deltaInternalMs: deltaInternal,
deltaExternalMs: deltaExternal,
deltaTotalMs: deltaTotal,
},
createdAt: now,
})
const bodyHtml = [
"<p><strong>Ajuste manual de horas</strong></p>",
"<ul>",
`<li>Horas internas: ${escapeHtml(formatWorkDuration(previousInternal))} &rarr; ${escapeHtml(formatWorkDuration(nextInternal))} (${escapeHtml(formatWorkDelta(deltaInternal))})</li>`,
`<li>Horas externas: ${escapeHtml(formatWorkDuration(previousExternal))} &rarr; ${escapeHtml(formatWorkDuration(nextExternal))} (${escapeHtml(formatWorkDelta(deltaExternal))})</li>`,
`<li>Total: ${escapeHtml(formatWorkDuration(previousTotal))} &rarr; ${escapeHtml(formatWorkDuration(nextTotal))} (${escapeHtml(formatWorkDelta(deltaTotal))})</li>`,
"</ul>",
`<p><strong>Motivo:</strong> ${escapeHtml(trimmedReason)}</p>`,
].join("")
const authorSnapshot: CommentAuthorSnapshot = {
name: viewer.user.name,
email: viewer.user.email,
avatarUrl: viewer.user.avatarUrl ?? undefined,
teams: viewer.user.teams ?? undefined,
}
await ctx.db.insert("ticketComments", {
ticketId,
authorId: actorId,
visibility: "INTERNAL",
body: bodyHtml,
authorSnapshot,
attachments: [],
createdAt: now,
updatedAt: now,
})
const perAgentTotals = await computeAgentWorkTotals(ctx, ticketId, now)
return {
ticketId,
totalWorkedMs: nextTotal,
internalWorkedMs: nextInternal,
externalWorkedMs: nextExternal,
serverNow: now,
perAgentTotals: perAgentTotals.map((item) => ({
agentId: item.agentId,
agentName: item.agentName,
agentEmail: item.agentEmail,
avatarUrl: item.avatarUrl,
totalWorkedMs: item.totalWorkedMs,
internalWorkedMs: item.internalWorkedMs,
externalWorkedMs: item.externalWorkedMs,
})),
}
},
})
export const updateSubject = mutation({
args: { ticketId: v.id("tickets"), subject: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, subject, actorId }) => {

View file

@ -61,7 +61,6 @@
"pdfkit": "^0.17.2",
"postcss": "^8.5.6",
"react": "19.2.0",
"react-aria-components": "^1.4.0",
"react-dom": "19.2.0",
"react-hook-form": "^7.64.0",
"recharts": "^2.15.4",

15
pnpm-lock.yaml generated
View file

@ -137,9 +137,6 @@ importers:
react:
specifier: 19.2.0
version: 19.2.0
react-aria-components:
specifier: ^1.4.0
version: 1.13.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-dom:
specifier: 19.2.0
version: 19.2.0(react@19.2.0)
@ -4918,17 +4915,9 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-aria-components@1.13.0:
resolution: {integrity: sha512-t1mm3AVy/MjUJBZ7zrb+sFC5iya8Vvw3go3mGKtTm269bXGZho7BLA4IgT+0nOS3j+ku6ChVi8NEoQVFoYzJJA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-aria-components@1.13.0: {}
react-aria@3.44.0:
resolution: {integrity: sha512-2Pq3GQxBgM4/2BlpKYXeaZ47a3tdIcYSW/AYvKgypE3XipxOdQMDG5Sr/NBn7zuJq+thzmtfRb0lB9bTbsmaRw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-aria@3.44.0: {}
react-dom@19.2.0:
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}

View file

@ -95,7 +95,7 @@ export default async function AdminUsersPage() {
}
>
<div className="mx-auto w-full max-w-7xl px-4 pb-12 lg:px-8">
<AdminUsersWorkspace initialAccounts={accounts} companies={companies} />
<AdminUsersWorkspace initialAccounts={accounts} companies={companies} tenantId={tenantId} />
</div>
</AppShell>
)

View file

@ -0,0 +1,89 @@
import { NextResponse } from "next/server"
import { z } from "zod"
import { ConvexHttpClient } from "convex/browser"
import { assertStaffSession } from "@/lib/auth-server"
import type { Id } from "@/convex/_generated/dataModel"
export const runtime = "nodejs"
const schema = z.object({
machineId: z.string().min(1),
provider: z.string().optional(),
identifier: z.string().optional(),
url: z.string().optional(),
notes: z.string().optional(),
action: z.enum(["save", "clear"]).optional(),
})
export async function POST(request: Request) {
const session = await assertStaffSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
if (!session.user.id) {
return NextResponse.json({ error: "Sessão inválida" }, { status: 400 })
}
let parsed: z.infer<typeof schema>
try {
const body = await request.json()
parsed = schema.parse(body)
} catch {
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
}
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
}
const client = new ConvexHttpClient(convexUrl)
try {
const action = parsed.action ?? "save"
if (action === "clear") {
const result = (await (client as unknown as { mutation: (name: string, args: Record<string, unknown>) => Promise<unknown> }).mutation("machines:updateRemoteAccess", {
machineId: parsed.machineId as Id<"machines">,
actorId: session.user.id as Id<"users">,
clear: true,
})) as { remoteAccess?: unknown } | null
return NextResponse.json({ ok: true, remoteAccess: result?.remoteAccess ?? null })
}
const provider = (parsed.provider ?? "").trim()
const identifier = (parsed.identifier ?? "").trim()
if (!provider || !identifier) {
return NextResponse.json({ error: "Informe o provedor e o identificador do acesso remoto." }, { status: 400 })
}
let normalizedUrl: string | undefined
const rawUrl = (parsed.url ?? "").trim()
if (rawUrl.length > 0) {
const candidate = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`
try {
new URL(candidate)
normalizedUrl = candidate
} catch {
return NextResponse.json({ error: "URL inválida. Informe um endereço iniciado com http:// ou https://." }, { status: 422 })
}
}
const notes = (parsed.notes ?? "").trim()
const result = (await (client as unknown as { mutation: (name: string, args: Record<string, unknown>) => Promise<unknown> }).mutation("machines:updateRemoteAccess", {
machineId: parsed.machineId as Id<"machines">,
actorId: session.user.id as Id<"users">,
provider,
identifier,
url: normalizedUrl,
notes: notes.length ? notes : undefined,
})) as { remoteAccess?: unknown } | null
return NextResponse.json({ ok: true, remoteAccess: result?.remoteAccess ?? null })
} catch (error) {
console.error("[machines.remote-access]", error)
return NextResponse.json({ error: "Falha ao atualizar acesso remoto" }, { status: 500 })
}
}

View file

@ -1,7 +1,10 @@
import { NextResponse } from "next/server"
import { randomBytes } from "crypto"
import { ConvexHttpClient } from "convex/browser"
import { assertAdminSession } from "@/lib/auth-server"
import { prisma } from "@/lib/prisma"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
@ -23,11 +26,51 @@ export async function POST(request: Request) {
const client = new ConvexHttpClient(convexUrl)
try {
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
const company = await prisma.company.findUnique({
where: { id: companyId },
select: { id: true, tenantId: true, slug: true, name: true, provisioningCode: true },
})
if (!company || company.tenantId !== tenantId) {
return NextResponse.json({ error: "Empresa não encontrada" }, { status: 404 })
}
let provisioningCode = company.provisioningCode
if (!provisioningCode) {
provisioningCode = randomBytes(16).toString("hex")
await prisma.company.update({ where: { id: company.id }, data: { provisioningCode } })
}
const ensured = await client.mutation(api.users.ensureUser, {
tenantId,
email: session.user.email ?? "admin@sistema.dev",
name: session.user.name ?? session.user.email ?? "Administrador",
avatarUrl: session.user.avatarUrl ?? undefined,
role: session.user.role?.toUpperCase?.(),
})
if (!ensured?._id) {
throw new Error("Não foi possível identificar o ator no Convex")
}
const ensuredCompany = await client.mutation(api.companies.ensureProvisioned, {
tenantId,
slug: company.slug ?? `company-${company.id}`,
name: company.name,
provisioningCode,
})
if (!ensuredCompany?.id) {
throw new Error("Falha ao sincronizar empresa no Convex")
}
await client.mutation(api.users.assignCompany, {
tenantId: session.user.tenantId ?? "tenant-atlas",
tenantId,
email,
companyId: companyId as Id<"companies">,
actorId: (session.user as unknown as { convexUserId?: Id<"users">; id?: Id<"users"> }).convexUserId ?? (session.user.id as unknown as Id<"users">),
companyId: ensuredCompany.id as Id<"companies">,
actorId: ensured._id,
})
return NextResponse.json({ ok: true })
} catch (error) {

View file

@ -1,9 +1,14 @@
import { NextResponse } from "next/server"
import { hashPassword } from "better-auth/crypto"
import { ConvexHttpClient } from "convex/browser"
import type { UserRole } from "@prisma/client"
import { prisma } from "@/lib/prisma"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { assertStaffSession } from "@/lib/auth-server"
import { assertAdminSession, assertStaffSession } from "@/lib/auth-server"
import { isAdmin } from "@/lib/authz"
import { api } from "@/convex/_generated/api"
export const runtime = "nodejs"
@ -16,6 +21,17 @@ function normalizeRole(role?: string | null): AllowedRole {
return ALLOWED_ROLES.includes(normalized as AllowedRole) ? (normalized as AllowedRole) : "COLLABORATOR"
}
function generatePassword(length = 12) {
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%&*?"
let password = ""
const array = new Uint32Array(length)
crypto.getRandomValues(array)
for (let index = 0; index < length; index += 1) {
password += charset[array[index] % charset.length]
}
return password
}
export async function GET() {
const session = await assertStaffSession()
if (!session) {
@ -97,6 +113,111 @@ export async function GET() {
return NextResponse.json({ items })
}
export async function POST(request: Request) {
const session = await assertAdminSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
const body = (await request.json().catch(() => null)) as {
name?: string
email?: string
role?: string
tenantId?: string
} | null
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "Payload inválido" }, { status: 400 })
}
const name = body.name?.trim() ?? ""
const email = body.email?.trim().toLowerCase() ?? ""
const tenantId = (body.tenantId ?? session.user.tenantId ?? DEFAULT_TENANT_ID).trim() || DEFAULT_TENANT_ID
if (!name) {
return NextResponse.json({ error: "Informe o nome do usuário" }, { status: 400 })
}
if (!email || !email.includes("@")) {
return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 })
}
const normalizedRole = normalizeRole(body.role)
const authRole = normalizedRole.toLowerCase()
const userRole = normalizedRole as UserRole
const existingAuth = await prisma.authUser.findUnique({ where: { email } })
if (existingAuth) {
return NextResponse.json({ error: "Já existe um usuário com este e-mail" }, { status: 409 })
}
const temporaryPassword = generatePassword()
const hashedPassword = await hashPassword(temporaryPassword)
const [authUser, domainUser] = await prisma.$transaction(async (tx) => {
const createdAuthUser = await tx.authUser.create({
data: {
email,
name,
role: authRole,
tenantId,
accounts: {
create: {
providerId: "credential",
accountId: email,
password: hashedPassword,
},
},
},
})
const createdDomainUser = await tx.user.upsert({
where: { email },
update: {
name,
role: userRole,
tenantId,
},
create: {
name,
email,
role: userRole,
tenantId,
},
})
return [createdAuthUser, createdDomainUser] as const
})
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
if (convexUrl) {
try {
const convex = new ConvexHttpClient(convexUrl)
await convex.mutation(api.users.ensureUser, {
tenantId,
email,
name,
avatarUrl: authUser.avatarUrl ?? undefined,
role: userRole,
})
} catch (error) {
console.error("[admin/users] ensureUser failed", error)
}
}
return NextResponse.json({
user: {
id: domainUser.id,
authUserId: authUser.id,
email: domainUser.email,
name: domainUser.name,
role: authRole,
tenantId: domainUser.tenantId,
createdAt: domainUser.createdAt.toISOString(),
},
temporaryPassword,
})
}
export async function DELETE(request: Request) {
const session = await assertStaffSession()
if (!session) {

View file

@ -36,12 +36,13 @@ import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Checkbox } from "@/components/ui/checkbox"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import {
Table,
TableBody,
@ -783,6 +784,19 @@ const statusClasses: Record<string, string> = {
unknown: "border-slate-300 bg-slate-200 text-slate-700",
}
const REMOTE_ACCESS_PROVIDERS = [
{ value: "TEAMVIEWER", label: "TeamViewer" },
{ value: "ANYDESK", label: "AnyDesk" },
{ value: "SUPREMO", label: "Supremo" },
{ value: "RUSTDESK", label: "RustDesk" },
{ value: "QUICKSUPPORT", label: "TeamViewer QS" },
{ value: "CHROME_REMOTE_DESKTOP", label: "Chrome Remote Desktop" },
{ value: "DW_SERVICE", label: "DWService" },
{ value: "OTHER", label: "Outro" },
] as const
type RemoteAccessProviderValue = (typeof REMOTE_ACCESS_PROVIDERS)[number]["value"]
const POSTURE_ALERT_LABELS: Record<string, string> = {
CPU_HIGH: "CPU alta",
SERVICE_DOWN: "Serviço indisponível",
@ -1280,6 +1294,9 @@ type MachineDetailsProps = {
export function MachineDetails({ machine }: MachineDetailsProps) {
const router = useRouter()
const { role: viewerRole } = useAuth()
const normalizedViewerRole = (viewerRole ?? "").toLowerCase()
const canManageRemoteAccess = normalizedViewerRole === "admin" || normalizedViewerRole === "agent"
const effectiveStatus = machine ? resolveMachineStatus(machine) : "unknown"
const isActive = machine?.isActive ?? true
const isDeactivated = !isActive || effectiveStatus === "deactivated"
@ -1839,6 +1856,15 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
(machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator"
)
const [savingAccess, setSavingAccess] = useState(false)
const [remoteAccessDialog, setRemoteAccessDialog] = useState(false)
const [remoteAccessProviderOption, setRemoteAccessProviderOption] = useState<RemoteAccessProviderValue>(
REMOTE_ACCESS_PROVIDERS[0].value,
)
const [remoteAccessCustomProvider, setRemoteAccessCustomProvider] = useState("")
const [remoteAccessIdentifierInput, setRemoteAccessIdentifierInput] = useState("")
const [remoteAccessUrlInput, setRemoteAccessUrlInput] = useState("")
const [remoteAccessNotesInput, setRemoteAccessNotesInput] = useState("")
const [remoteAccessSaving, setRemoteAccessSaving] = useState(false)
const [togglingActive, setTogglingActive] = useState(false)
const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false)
const jsonText = useMemo(() => {
@ -1883,6 +1909,35 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
setAccessRole((machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator")
}, [machine?.id, machine?.persona, collaborator?.email, collaborator?.name, collaborator?.role])
useEffect(() => {
if (!remoteAccessDialog) return
const providerName = remoteAccess?.provider ?? ""
const matched = REMOTE_ACCESS_PROVIDERS.find(
(option) => option.value !== "OTHER" && option.label.toLowerCase() === providerName.toLowerCase(),
)
if (matched) {
setRemoteAccessProviderOption(matched.value)
setRemoteAccessCustomProvider("")
} else {
setRemoteAccessProviderOption(providerName ? "OTHER" : REMOTE_ACCESS_PROVIDERS[0].value)
setRemoteAccessCustomProvider(providerName ?? "")
}
setRemoteAccessIdentifierInput(remoteAccess?.identifier ?? "")
setRemoteAccessUrlInput(remoteAccess?.url ?? "")
setRemoteAccessNotesInput(remoteAccess?.notes ?? "")
}, [remoteAccessDialog, remoteAccess])
useEffect(() => {
if (remoteAccessDialog) return
if (!remoteAccess) {
setRemoteAccessProviderOption(REMOTE_ACCESS_PROVIDERS[0].value)
setRemoteAccessCustomProvider("")
setRemoteAccessIdentifierInput("")
setRemoteAccessUrlInput("")
setRemoteAccessNotesInput("")
}
}, [remoteAccess, remoteAccessDialog])
useEffect(() => {
setShowAllWindowsSoftware(false)
}, [machine?.id])
@ -1923,6 +1978,120 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
}
}
const handleSaveRemoteAccess = useCallback(async () => {
if (!machine) return
if (!canManageRemoteAccess) {
toast.error("Você não tem permissão para ajustar o acesso remoto desta máquina.")
return
}
const providerOption = REMOTE_ACCESS_PROVIDERS.find((option) => option.value === remoteAccessProviderOption)
const providerName =
remoteAccessProviderOption === "OTHER"
? remoteAccessCustomProvider.trim()
: providerOption?.label ?? ""
if (!providerName) {
toast.error("Informe a ferramenta de acesso remoto.")
return
}
const identifier = remoteAccessIdentifierInput.trim()
if (!identifier) {
toast.error("Informe o ID ou código do acesso remoto.")
return
}
let normalizedUrl: string | undefined
const rawUrl = remoteAccessUrlInput.trim()
if (rawUrl.length > 0) {
const candidate = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`
try {
new URL(candidate)
normalizedUrl = candidate
} catch {
toast.error("Informe uma URL válida (ex: https://example.com).")
return
}
}
const notes = remoteAccessNotesInput.trim()
toast.dismiss("remote-access")
toast.loading("Salvando acesso remoto...", { id: "remote-access" })
setRemoteAccessSaving(true)
try {
const response = await fetch("/api/admin/machines/remote-access", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
machineId: machine.id,
provider: providerName,
identifier,
url: normalizedUrl,
notes: notes.length ? notes : undefined,
action: "save",
}),
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
const message = typeof payload?.error === "string" ? payload.error : "Falha ao atualizar acesso remoto."
throw new Error(message)
}
toast.success("Acesso remoto atualizado.", { id: "remote-access" })
setRemoteAccessDialog(false)
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao atualizar acesso remoto."
toast.error(message, { id: "remote-access" })
} finally {
setRemoteAccessSaving(false)
}
}, [
machine,
canManageRemoteAccess,
remoteAccessProviderOption,
remoteAccessCustomProvider,
remoteAccessIdentifierInput,
remoteAccessUrlInput,
remoteAccessNotesInput,
])
const handleRemoveRemoteAccess = useCallback(async () => {
if (!machine) return
if (!canManageRemoteAccess) {
toast.error("Você não tem permissão para ajustar o acesso remoto desta máquina.")
return
}
toast.dismiss("remote-access")
toast.loading("Removendo acesso remoto...", { id: "remote-access" })
setRemoteAccessSaving(true)
try {
const response = await fetch("/api/admin/machines/remote-access", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
machineId: machine.id,
action: "clear",
}),
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
const message = typeof payload?.error === "string" ? payload.error : "Falha ao remover acesso remoto."
throw new Error(message)
}
toast.success("Acesso remoto removido.", { id: "remote-access" })
setRemoteAccessDialog(false)
} catch (error) {
const message = error instanceof Error ? error.message : "Falha ao remover acesso remoto."
toast.error(message, { id: "remote-access" })
} finally {
setRemoteAccessSaving(false)
}
}, [machine, canManageRemoteAccess])
const handleToggleActive = async () => {
if (!machine) return
setTogglingActive(true)
@ -2036,61 +2205,78 @@ 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 className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-semibold">Acesso remoto</h4>
{remoteAccess?.provider ? (
<Badge variant="outline" className="border-indigo-200 bg-white text-[11px] font-semibold text-indigo-700">
{remoteAccess.provider}
</Badge>
) : null}
</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>
{canManageRemoteAccess ? (
<Button
size="sm"
variant="outline"
className="gap-2 border-dashed"
onClick={() => setRemoteAccessDialog(true)}
>
<Key className="size-4" />
{hasRemoteAccess ? "Editar acesso" : "Adicionar acesso"}
</Button>
) : null}
</div>
) : null}
{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-2">
{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="whitespace-pre-wrap 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>
) : (
<div className="rounded-lg border border-dashed border-indigo-200 bg-indigo-50/40 px-4 py-3 text-xs sm:text-sm text-slate-600">
Nenhum identificador de acesso remoto cadastrado. Registre o ID do TeamViewer, AnyDesk ou outra ferramenta para agilizar o suporte.
</div>
)}
</div>
</section>
<section className="space-y-2">
@ -2251,6 +2437,116 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</DialogContent>
</Dialog>
<Dialog
open={remoteAccessDialog}
onOpenChange={(open) => {
setRemoteAccessDialog(open)
if (!open) {
setRemoteAccessSaving(false)
}
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Detalhes de acesso remoto</DialogTitle>
<DialogDescription>
Registre o provedor e o identificador utilizado para acesso remoto à máquina.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
event.preventDefault()
void handleSaveRemoteAccess()
}}
className="space-y-4"
>
<div className="grid gap-2">
<label className="text-sm font-medium">Ferramenta</label>
<Select
value={remoteAccessProviderOption}
onValueChange={(value) => setRemoteAccessProviderOption(value as RemoteAccessProviderValue)}
>
<SelectTrigger>
<SelectValue placeholder="Selecione o provedor" />
</SelectTrigger>
<SelectContent>
{REMOTE_ACCESS_PROVIDERS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{remoteAccessProviderOption === "OTHER" ? (
<div className="grid gap-2">
<label className="text-sm font-medium">Nome da ferramenta</label>
<Input
value={remoteAccessCustomProvider}
onChange={(event) => setRemoteAccessCustomProvider(event.target.value)}
placeholder="Ex: Supremo, Zoho Assist..."
autoFocus
/>
</div>
) : null}
<div className="grid gap-2">
<label className="text-sm font-medium">ID / código</label>
<Input
value={remoteAccessIdentifierInput}
onChange={(event) => setRemoteAccessIdentifierInput(event.target.value)}
placeholder="Ex: 123 456 789"
required
/>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Link (opcional)</label>
<Input
value={remoteAccessUrlInput}
onChange={(event) => setRemoteAccessUrlInput(event.target.value)}
placeholder="https://"
/>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Observações</label>
<Textarea
value={remoteAccessNotesInput}
onChange={(event) => setRemoteAccessNotesInput(event.target.value)}
rows={3}
placeholder="Credencial compartilhada, PIN adicional, instruções..."
/>
</div>
<DialogFooter className="flex flex-wrap items-center justify-between gap-2">
{hasRemoteAccess ? (
<Button
type="button"
variant="outline"
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
onClick={handleRemoveRemoteAccess}
disabled={remoteAccessSaving}
>
Remover acesso
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={() => setRemoteAccessDialog(false)}
disabled={remoteAccessSaving}
>
Cancelar
</Button>
<Button type="submit" disabled={remoteAccessSaving}>
{remoteAccessSaving ? "Salvando..." : "Salvar"}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<section className="space-y-2">
<h4 className="text-sm font-semibold">Sincronização</h4>
<div className="grid gap-2 text-sm text-muted-foreground">

View file

@ -13,8 +13,10 @@ import {
IconNotebook,
IconPencil,
IconPlus,
IconCopy,
IconSearch,
IconTrash,
IconUserPlus,
IconUsers,
} from "@tabler/icons-react"
import { toast } from "sonner"
@ -33,6 +35,7 @@ import {
import type { NormalizedCompany } from "@/server/company-service"
import { cn } from "@/lib/utils"
import type { RoleOption } from "@/lib/authz"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@ -79,6 +82,7 @@ export type AdminAccount = {
type Props = {
initialAccounts: AdminAccount[]
companies: NormalizedCompany[]
tenantId: string
}
type SectionEditorState =
@ -103,10 +107,28 @@ const ROLE_OPTIONS_DISPLAY: ReadonlyArray<{ value: AdminAccount["role"]; label:
{ value: "COLLABORATOR", label: "Colaborador" },
]
const DEFAULT_CREATE_ROLE: AdminAccount["role"] = "COLLABORATOR"
const NO_COMPANY_SELECT_VALUE = "__none__"
const NO_CONTACT_VALUE = "__none__"
type CreateAccountFormState = {
name: string
email: string
role: AdminAccount["role"]
companyId: string
}
function createDefaultAccountForm(): CreateAccountFormState {
return {
name: "",
email: "",
role: DEFAULT_CREATE_ROLE,
companyId: NO_COMPANY_SELECT_VALUE,
}
}
function createId(prefix: string) {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return `${prefix}-${crypto.randomUUID()}`
@ -125,7 +147,7 @@ function FieldError({ message }: { message?: string }) {
return <p className="text-xs font-medium text-destructive">{message}</p>
}
export function AdminUsersWorkspace({ initialAccounts, companies }: Props) {
export function AdminUsersWorkspace({ initialAccounts, companies, tenantId }: Props) {
const [tab, setTab] = useState<"accounts" | "structure">("accounts")
return (
<Tabs value={tab} onValueChange={(value) => setTab(value as typeof tab)}>
@ -134,7 +156,7 @@ export function AdminUsersWorkspace({ initialAccounts, companies }: Props) {
<TabsTrigger value="structure">Estrutura das empresas</TabsTrigger>
</TabsList>
<TabsContent value="accounts">
<AccountsTable initialAccounts={initialAccounts} />
<AccountsTable initialAccounts={initialAccounts} companies={companies} tenantId={tenantId} />
</TabsContent>
<TabsContent value="structure">
<CompanyStructurePanel initialCompanies={companies} />
@ -143,7 +165,15 @@ export function AdminUsersWorkspace({ initialAccounts, companies }: Props) {
)
}
function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] }) {
function AccountsTable({
initialAccounts,
companies,
tenantId,
}: {
initialAccounts: AdminAccount[]
companies: NormalizedCompany[]
tenantId: string
}) {
const [accounts, setAccounts] = useState(initialAccounts)
const [search, setSearch] = useState("")
const [roleFilter, setRoleFilter] = useState<"all" | AdminAccount["role"]>("all")
@ -162,6 +192,11 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
const [isSavingAccount, setIsSavingAccount] = useState(false)
const [isResettingPassword, setIsResettingPassword] = useState(false)
const [passwordPreview, setPasswordPreview] = useState<string | null>(null)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [isCreatingAccount, setIsCreatingAccount] = useState(false)
const [createForm, setCreateForm] = useState<CreateAccountFormState>(() => createDefaultAccountForm())
const effectiveTenantId = tenantId || DEFAULT_TENANT_ID
const filteredAccounts = useMemo(() => {
const term = search.trim().toLowerCase()
@ -195,16 +230,6 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
[accounts, editAccountId]
)
const companies = useMemo(() => {
const map = new Map<string, string>()
accounts.forEach((account) => {
if (account.companyId && account.companyName) {
map.set(account.companyId, account.companyName)
}
})
return Array.from(map.entries()).map(([id, name]) => ({ id, name }))
}, [accounts])
const roleSelectOptions = useMemo<SearchableComboboxOption[]>(
() => ROLE_OPTIONS_DISPLAY.map((option) => ({ value: option.value, label: option.label })),
[],
@ -256,6 +281,16 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
setPasswordPreview(null)
}, [])
const handleOpenCreateDialog = useCallback(() => {
setCreateForm(createDefaultAccountForm())
setCreateDialogOpen(true)
}, [])
const handleCloseCreateDialog = useCallback(() => {
setCreateDialogOpen(false)
setCreateForm(createDefaultAccountForm())
}, [])
useEffect(() => {
if (editAccount) {
setEditForm({
@ -375,6 +410,88 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
}
}, [editAuthUserId])
const handleCreateAccount = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const name = createForm.name.trim()
const email = createForm.email.trim().toLowerCase()
if (!name) {
toast.error("Informe o nome do usuário.")
return
}
if (!email || !email.includes("@")) {
toast.error("Informe um e-mail válido.")
return
}
const payload = {
name,
email,
role: ROLE_TO_OPTION[createForm.role] ?? "collaborator",
tenantId: effectiveTenantId,
}
setIsCreatingAccount(true)
try {
const response = await fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
})
if (!response.ok) {
const data = await response.json().catch(() => null)
throw new Error(data?.error ?? "Não foi possível criar o usuário.")
}
const data = (await response.json()) as { user: { email: string }; temporaryPassword?: string }
let assignError: string | null = null
if (createForm.companyId && createForm.companyId !== NO_COMPANY_SELECT_VALUE) {
const assignResponse = await fetch("/api/admin/users/assign-company", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, companyId: createForm.companyId }),
})
if (!assignResponse.ok) {
const assignData = await assignResponse.json().catch(() => null)
assignError = assignData?.error ?? "Usuário criado, mas não foi possível vincular a empresa."
}
}
await refreshAccounts()
setCreateForm(createDefaultAccountForm())
setCreateDialogOpen(false)
toast.success("Usuário criado com sucesso.", {
description: data.temporaryPassword ? `Senha temporária: ${data.temporaryPassword}` : undefined,
action: data.temporaryPassword
? {
label: "Copiar",
onClick: async () => {
try {
await navigator.clipboard?.writeText?.(data.temporaryPassword ?? "")
toast.success("Senha copiada para a área de transferência.")
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível copiar a senha."
toast.error(message)
}
},
}
: undefined,
})
if (assignError) {
toast.error(assignError)
}
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao criar usuário."
toast.error(message)
} finally {
setIsCreatingAccount(false)
}
},
[createForm, effectiveTenantId, refreshAccounts]
)
const handleDelete = useCallback(
(ids: string[]) => {
if (ids.length === 0) return
@ -409,7 +526,8 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
)
return (
<Card>
<>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold">Usuários do cliente</CardTitle>
<CardDescription>Gestores e colaboradores com acesso ao portal de chamados.</CardDescription>
@ -452,16 +570,20 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="destructive"
disabled={selectedIds.length === 0 || isPending}
onClick={() => openDeleteDialog(selectedIds)}
>
<IconTrash className="mr-2 size-4" />
Remover
</Button>
</div>
<div className="flex items-center gap-2">
<Button type="button" className="gap-2" onClick={handleOpenCreateDialog}>
<IconUserPlus className="size-4" />
Novo usuário
</Button>
<Button
variant="destructive"
disabled={selectedIds.length === 0 || isPending}
onClick={() => openDeleteDialog(selectedIds)}
>
<IconTrash className="mr-2 size-4" />
Remover
</Button>
</div>
</div>
<div className="overflow-x-auto">
@ -716,6 +838,89 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] })
</SheetContent>
</Sheet>
</Card>
<Dialog open={createDialogOpen} onOpenChange={(open) => (!open ? handleCloseCreateDialog() : null)}>
<DialogContent className="max-w-lg space-y-6">
<DialogHeader>
<DialogTitle>Novo usuário</DialogTitle>
<DialogDescription>
Crie acessos para gestores ou colaboradores vinculados às empresas cadastradas.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreateAccount} className="space-y-5">
<div className="grid gap-2">
<Label htmlFor="create-name">Nome</Label>
<Input
id="create-name"
value={createForm.name}
onChange={(event) => setCreateForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Nome completo"
disabled={isCreatingAccount}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="create-email">E-mail</Label>
<Input
id="create-email"
type="email"
value={createForm.email}
onChange={(event) => setCreateForm((prev) => ({ ...prev, email: event.target.value }))}
placeholder="usuario@empresa.com"
disabled={isCreatingAccount}
required
/>
</div>
<div className="grid gap-2">
<Label>Papel</Label>
<Select
value={createForm.role}
onValueChange={(value) =>
setCreateForm((prev) => ({ ...prev, role: (value as AdminAccount["role"]) ?? prev.role }))
}
disabled={isCreatingAccount}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{ROLE_OPTIONS_DISPLAY.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Empresa vinculada</Label>
<SearchableCombobox
value={createForm.companyId}
onValueChange={(value) =>
setCreateForm((prev) => ({
...prev,
companyId: value === null ? NO_COMPANY_SELECT_VALUE : value,
}))
}
options={editCompanyOptions}
placeholder="Sem empresa vinculada"
searchPlaceholder="Buscar empresa..."
disabled={isCreatingAccount}
/>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="ghost" onClick={handleCloseCreateDialog} disabled={isCreatingAccount}>
Cancelar
</Button>
<Button type="submit" disabled={isCreatingAccount}>
{isCreatingAccount ? "Criando..." : "Criar usuário"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -38,6 +38,8 @@ const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-
const submitButtonClass =
"inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
type CommentsOrder = "descending" | "ascending"
export function TicketComments({ ticket }: TicketCommentsProps) {
const { convexUserId, isStaff, role } = useAuth()
const normalizedRole = role ?? null
@ -66,6 +68,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const [editingComment, setEditingComment] = useState<{ id: string; value: string } | null>(null)
const [savingCommentId, setSavingCommentId] = useState<string | null>(null)
const [localBodies, setLocalBodies] = useState<Record<string, string>>({})
const [commentsOrder, setCommentsOrder] = useState<CommentsOrder>("descending")
const templateArgs = convexUserId && isStaff
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users">, kind: "comment" as const }
@ -133,8 +136,16 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
)
const commentsAll = useMemo(() => {
return [...pending, ...ticket.comments]
}, [pending, ticket.comments])
const base = [...pending, ...ticket.comments]
return base.sort((a, b) => {
const aTime = new Date(a.createdAt).getTime()
const bTime = new Date(b.createdAt).getTime()
if (commentsOrder === "ascending") {
return aTime - bTime
}
return bTime - aTime
})
}, [pending, ticket.comments, commentsOrder])
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
@ -232,6 +243,36 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-4 pb-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium text-neutral-600">
<IconMessage className="size-4" />
<span>{commentsAll.length} {commentsAll.length === 1 ? "comentário" : "comentários"}</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="flex items-center gap-2 text-sm text-neutral-600 hover:text-neutral-900"
onClick={() => setCommentsOrder((prev) => (prev === "descending" ? "ascending" : "descending"))}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-4"
focusable="false"
>
<path
d="M7 18.5v-13M7 5.5L4 8.5M7 5.5l3 3M17 5.5v13M17 18.5l-3-3M17 18.5l3-3"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
{commentsOrder === "descending" ? "Mais recentes primeiro" : "Mais antigos primeiro"}
</Button>
</div>
{commentsAll.length === 0 ? (
<Empty>
<EmptyHeader>

View file

@ -1,9 +1,9 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"
import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconDownload, IconPlayerPause, IconPlayerPlay, IconPencil } from "@tabler/icons-react"
import { IconClock, IconDownload, IconPlayerPause, IconPlayerPlay, IconPencil, IconAdjustmentsHorizontal } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
@ -21,6 +21,7 @@ import { CheckCircle2 } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
@ -133,6 +134,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const normalizedRole = (role ?? "").toLowerCase()
const isManager = normalizedRole === "manager"
const isAdmin = normalizedRole === "admin"
const canAdjustWork = isAdmin || normalizedRole === "agent"
const sessionName = session?.user?.name?.trim()
const machineAssignedName = machineContext?.assignedUserName?.trim()
const agentName =
@ -163,6 +165,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const updateSummary = useMutation(api.tickets.updateSummary)
const startWork = useMutation(api.tickets.startWork)
const pauseWork = useMutation(api.tickets.pauseWork)
const adjustWorkSummaryMutation = useMutation(api.tickets.adjustWorkSummary)
const updateCategories = useMutation(api.tickets.updateCategories)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const queuesEnabled = Boolean(isStaff && convexUserId)
@ -248,6 +251,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [pausing, setPausing] = useState(false)
const [exportingPdf, setExportingPdf] = useState(false)
const [closeOpen, setCloseOpen] = useState(false)
const [adjustDialogOpen, setAdjustDialogOpen] = useState(false)
const [adjustInternalHours, setAdjustInternalHours] = useState("")
const [adjustExternalHours, setAdjustExternalHours] = useState("")
const [adjustReason, setAdjustReason] = useState("")
const [adjusting, setAdjusting] = useState(false)
const [assigneeChangeReason, setAssigneeChangeReason] = useState("")
const [assigneeReasonError, setAssigneeReasonError] = useState<string | null>(null)
const [companySelection, setCompanySelection] = useState<string>(NO_COMPANY_VALUE)
@ -644,6 +652,25 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
])
const [workSummary, setWorkSummary] = useState<WorkSummarySnapshot | null>(initialWorkSummary)
const formatHoursInput = useCallback((ms: number) => {
if (!Number.isFinite(ms) || ms <= 0) {
return "0"
}
const hours = ms / 3600000
const rounded = Math.round(hours * 100) / 100
return rounded.toString()
}, [])
const effectiveWorkSummary = workSummary ?? initialWorkSummary
useEffect(() => {
if (!adjustDialogOpen) return
const internalMs = effectiveWorkSummary?.internalWorkedMs ?? 0
const externalMs = effectiveWorkSummary?.externalWorkedMs ?? 0
setAdjustInternalHours(formatHoursInput(internalMs))
setAdjustExternalHours(formatHoursInput(externalMs))
setAdjustReason("")
}, [adjustDialogOpen, effectiveWorkSummary, formatHoursInput])
const serverOffsetRef = useRef<number>(0)
const calibrateServerOffset = useCallback(
@ -944,6 +971,116 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}
}
const handleAdjustSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!convexUserId) {
toast.error("Sessão expirada. Faça login novamente.")
return
}
const parseHours = (value: string) => {
const normalized = value.replace(",", ".").trim()
if (normalized.length === 0) return 0
const numeric = Number.parseFloat(normalized)
if (!Number.isFinite(numeric) || numeric < 0) return null
return numeric
}
const internalHoursParsed = parseHours(adjustInternalHours)
if (internalHoursParsed === null) {
toast.error("Informe um valor válido para horas internas.")
return
}
const externalHoursParsed = parseHours(adjustExternalHours)
if (externalHoursParsed === null) {
toast.error("Informe um valor válido para horas externas.")
return
}
const trimmedReason = adjustReason.trim()
if (trimmedReason.length < 5) {
toast.error("Descreva o motivo do ajuste (mínimo de 5 caracteres).")
return
}
toast.dismiss("adjust-hours")
toast.loading("Ajustando horas...", { id: "adjust-hours" })
setAdjusting(true)
try {
const targetInternalMs = Math.round(internalHoursParsed * 3600000)
const targetExternalMs = Math.round(externalHoursParsed * 3600000)
const result = (await adjustWorkSummaryMutation({
ticketId: ticket.id as Id<"tickets">,
actorId: convexUserId as Id<"users">,
internalWorkedMs: targetInternalMs,
externalWorkedMs: targetExternalMs,
reason: trimmedReason,
})) as {
ticketId: Id<"tickets">
totalWorkedMs: number
internalWorkedMs: number
externalWorkedMs: number
serverNow?: number
perAgentTotals?: Array<{
agentId: string
agentName: string | null
agentEmail: string | null
avatarUrl: string | null
totalWorkedMs: number
internalWorkedMs: number
externalWorkedMs: number
}>
}
calibrateServerOffset(result?.serverNow ?? null)
setWorkSummary((prev) => {
const base: WorkSummarySnapshot =
prev ??
({
ticketId: ticket.id as Id<"tickets">,
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
serverNow: result?.serverNow ?? null,
activeSession: null,
perAgentTotals: [],
} satisfies WorkSummarySnapshot)
return {
...base,
totalWorkedMs: result?.totalWorkedMs ?? base.totalWorkedMs,
internalWorkedMs: result?.internalWorkedMs ?? base.internalWorkedMs,
externalWorkedMs: result?.externalWorkedMs ?? base.externalWorkedMs,
serverNow: result?.serverNow ?? base.serverNow,
perAgentTotals: result?.perAgentTotals
? result.perAgentTotals.map((item) => ({
agentId: item.agentId,
agentName: item.agentName ?? null,
agentEmail: item.agentEmail ?? null,
avatarUrl: item.avatarUrl ?? null,
totalWorkedMs: item.totalWorkedMs,
internalWorkedMs: item.internalWorkedMs,
externalWorkedMs: item.externalWorkedMs,
}))
: base.perAgentTotals,
}
})
toast.success("Horas ajustadas com sucesso.", { id: "adjust-hours" })
setAdjustDialogOpen(false)
setAdjustReason("")
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível ajustar as horas."
toast.error(message, { id: "adjust-hours" })
} finally {
setAdjusting(false)
}
},
[
adjustInternalHours,
adjustExternalHours,
adjustReason,
adjustWorkSummaryMutation,
calibrateServerOffset,
convexUserId,
ticket.id,
],
)
const handleExportPdf = useCallback(async () => {
try {
setExportingPdf(true)
@ -999,6 +1136,17 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</TooltipContent>
</Tooltip>
) : null}
{canAdjustWork && workSummary ? (
<Button
type="button"
variant="outline"
size="sm"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-slate-50"
onClick={() => setAdjustDialogOpen(true)}
>
<IconAdjustmentsHorizontal className="size-4" /> Ajustar horas
</Button>
) : null}
{!editing ? (
<Button
size="icon"
@ -1033,6 +1181,71 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
agentName={agentName}
onSuccess={() => setStatus("RESOLVED")}
/>
<Dialog open={adjustDialogOpen} onOpenChange={setAdjustDialogOpen}>
<DialogContent className="max-w-lg">
<form onSubmit={handleAdjustSubmit} className="space-y-6">
<DialogHeader>
<DialogTitle>Ajustar horas do chamado</DialogTitle>
<DialogDescription>
Atualize os tempos registrados e descreva o motivo do ajuste. Apenas agentes e administradores visualizam este log.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="adjust-internal">Horas internas</Label>
<Input
id="adjust-internal"
type="number"
min={0}
step={0.25}
value={adjustInternalHours}
onChange={(event) => setAdjustInternalHours(event.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="adjust-external">Horas externas</Label>
<Input
id="adjust-external"
type="number"
min={0}
step={0.25}
value={adjustExternalHours}
onChange={(event) => setAdjustExternalHours(event.target.value)}
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="adjust-reason">Motivo do ajuste</Label>
<Textarea
id="adjust-reason"
value={adjustReason}
onChange={(event) => setAdjustReason(event.target.value)}
placeholder="Descreva por que o tempo precisa ser ajustado..."
rows={4}
required
/>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => {
setAdjustDialogOpen(false)
setAdjusting(false)
}}
disabled={adjusting}
>
Cancelar
</Button>
<Button type="submit" disabled={adjusting}>
{adjusting ? "Salvando..." : "Salvar ajuste"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">

View file

@ -1,7 +1,8 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { formatDistanceToNowStrict } from "date-fns"
import { formatDistanceStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { LayoutGrid } from "lucide-react"
@ -17,11 +18,72 @@ type TicketsBoardProps = {
enteringIds?: Set<string>
}
function formatUpdated(date: Date) {
return formatDistanceToNowStrict(date, { addSuffix: true, locale: ptBR })
const SECOND = 1_000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
function getTimestamp(value: Date | number | string | null | undefined) {
if (value == null) return null
if (typeof value === "number") {
return Number.isFinite(value) ? value : null
}
const parsed = value instanceof Date ? value.getTime() : new Date(value).getTime()
return Number.isFinite(parsed) ? parsed : null
}
function getNextDelay(diff: number) {
if (diff < MINUTE) {
return SECOND
}
if (diff < HOUR) {
const pastMinute = diff % MINUTE
return pastMinute === 0 ? MINUTE : MINUTE - pastMinute
}
if (diff < DAY) {
const pastHour = diff % HOUR
return pastHour === 0 ? HOUR : HOUR - pastHour
}
const pastDay = diff % DAY
return pastDay === 0 ? DAY : DAY - pastDay
}
function formatUpdated(date: Date | number | string, now: number) {
const timestamp = getTimestamp(date)
if (timestamp === null) return "—"
return formatDistanceStrict(timestamp, now, { addSuffix: true, locale: ptBR })
}
export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
const [now, setNow] = useState(() => Date.now())
const ticketTimestamps = useMemo(() => {
return tickets
.map((ticket) => getTimestamp(ticket.updatedAt))
.filter((value): value is number => value !== null)
}, [tickets])
useEffect(() => {
if (ticketTimestamps.length === 0) {
return
}
let minDelay = DAY
for (const timestamp of ticketTimestamps) {
const diff = Math.abs(now - timestamp)
const candidate = Math.max(SECOND, getNextDelay(diff))
if (candidate < minDelay) {
minDelay = candidate
}
}
const timeoutId = window.setTimeout(() => setNow(Date.now()), minDelay)
return () => window.clearTimeout(timeoutId)
}, [ticketTimestamps, now])
if (!tickets.length) {
return (
<div className="rounded-3xl border border-slate-200 bg-white p-12 text-center shadow-sm">
@ -45,70 +107,70 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
{tickets.map((ticket) => {
const isEntering = enteringIds?.has(ticket.id)
return (
<Link
key={ticket.id}
href={`/tickets/${ticket.id}`}
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",
isEntering ? "recent-ticket-enter" : ""
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<Badge
variant="outline"
className="border-slate-200 bg-slate-100 px-3.5 py-1.5 text-xs font-semibold text-neutral-700"
>
#{ticket.reference}
</Badge>
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition",
getTicketStatusChipClass(ticket.status),
)}
>
{getTicketStatusLabel(ticket.status)}
<Link
key={ticket.id}
href={`/tickets/${ticket.id}`}
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",
isEntering ? "recent-ticket-enter" : ""
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<Badge
variant="outline"
className="border-slate-200 bg-slate-100 px-3.5 py-1.5 text-xs font-semibold text-neutral-700"
>
#{ticket.reference}
</Badge>
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition",
getTicketStatusChipClass(ticket.status),
)}
>
{getTicketStatusLabel(ticket.status)}
</span>
</div>
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
{formatUpdated(ticket.updatedAt, now)}
</span>
</div>
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-400">
{formatUpdated(ticket.updatedAt)}
</span>
</div>
<h3 className="mt-5 line-clamp-2 text-lg font-semibold text-neutral-900">
{ticket.subject || "Sem assunto"}
</h3>
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-neutral-600">
<span className="font-medium text-neutral-500">Fila:</span>
<span className="rounded-full bg-slate-100 px-3.5 py-0.5 text-xs font-medium text-neutral-700">
{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>
<dl className="mt-6 space-y-2.5 text-sm text-neutral-600">
<div className="flex items-start justify-between gap-4">
<dt className="font-medium text-neutral-500">Empresa</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.company?.name ?? "Sem empresa"}
</dd>
<h3 className="mt-5 line-clamp-2 text-lg font-semibold text-neutral-900">
{ticket.subject || "Sem assunto"}
</h3>
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-neutral-600">
<span className="font-medium text-neutral-500">Fila:</span>
<span className="rounded-full bg-slate-100 px-3.5 py-0.5 text-xs font-medium text-neutral-700">
{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 className="flex items-start justify-between gap-4">
<dt className="font-medium text-neutral-500">Responsável</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.assignee?.name ?? "Sem responsável"}
</dd>
</div>
<div className="flex items-start justify-between gap-4">
<dt className="font-medium text-neutral-500">Solicitante</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.requester?.name ?? ticket.requester?.email ?? "—"}
</dd>
</div>
</dl>
</Link>
<dl className="mt-6 space-y-2.5 text-sm text-neutral-600">
<div className="flex items-start justify-between gap-4">
<dt className="font-medium text-neutral-500">Empresa</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.company?.name ?? "Sem empresa"}
</dd>
</div>
<div className="flex items-start justify-between gap-4">
<dt className="font-medium text-neutral-500">Responsável</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.assignee?.name ?? "Sem responsável"}
</dd>
</div>
<div className="flex items-start justify-between gap-4">
<dt className="font-medium text-neutral-500">Solicitante</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.requester?.name ?? ticket.requester?.email ?? "—"}
</dd>
</div>
</dl>
</Link>
)
})}
</div>

View file

@ -0,0 +1,40 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border border-border/60 bg-background p-4 [&>svg+div]:translate-y-0 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-muted-foreground [&>svg~*]:pl-10",
{
variants: {
variant: {
default: "border-border/60 text-foreground",
destructive:
"border-destructive/60 text-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
))
Alert.displayName = "Alert"
function AlertTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h5 className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
}
AlertTitle.displayName = "AlertTitle"
function AlertDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <div className={cn("text-sm text-muted-foreground [&_p]:leading-relaxed", className)} {...props} />
}
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertDescription, AlertTitle }

View file

@ -257,6 +257,7 @@ function Sidebar({
function SidebarTrigger({
className,
onClick,
tabIndex,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
@ -266,29 +267,21 @@ function SidebarTrigger({
setHydrated(true)
}, [])
if (!hydrated) {
return (
<div
data-sidebar="trigger"
data-slot="sidebar-trigger"
className={cn("size-7 rounded-full border border-slate-200 bg-white", className)}
role="presentation"
/>
)
}
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
disabled={!hydrated}
aria-hidden={hydrated ? undefined : true}
tabIndex={hydrated ? tabIndex : -1}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>