feat: cadastro manual de acesso remoto e ajustes de horas
This commit is contained in:
parent
8e3cbc7a9a
commit
f3a7045691
16 changed files with 1549 additions and 207 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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))} → ${escapeHtml(formatWorkDuration(nextInternal))} (${escapeHtml(formatWorkDelta(deltaInternal))})</li>`,
|
||||
`<li>Horas externas: ${escapeHtml(formatWorkDuration(previousExternal))} → ${escapeHtml(formatWorkDuration(nextExternal))} (${escapeHtml(formatWorkDelta(deltaExternal))})</li>`,
|
||||
`<li>Total: ${escapeHtml(formatWorkDuration(previousTotal))} → ${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 }) => {
|
||||
|
|
|
|||
|
|
@ -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
15
pnpm-lock.yaml
generated
|
|
@ -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==}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
89
src/app/api/admin/machines/remote-access/route.ts
Normal file
89
src/app/api/admin/machines/remote-access/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
40
src/components/ui/alert.tsx
Normal file
40
src/components/ui/alert.tsx
Normal 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 }
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue