feat: melhorias de UX e redesign de comentários
- Corrige sincronização do avatar no perfil após upload - Reduz tamanho dos ícones de câmera/lixeira no avatar - Remove atributos title (tooltips nativos) de toda aplicação - Adiciona regra no AGENTS.md sobre uso de tooltips - Permite desmarcar resposta no checklist (toggle) - Torna campo answer opcional na mutation setChecklistItemAnswer - Adiciona edição inline dos campos de resumo no painel de detalhes - Redesenha comentários com layout mais limpo e consistente - Cria tratamento especial para comentários automáticos de sistema - Aplica fundo ciano semi-transparente em comentários públicos - Corrige import do Loader2 no notification-preferences-form 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
23ea426c68
commit
022e1f63ba
17 changed files with 636 additions and 180 deletions
45
agents.md
45
agents.md
|
|
@ -167,5 +167,48 @@ bun run build:bun
|
|||
- `docs/DEPLOY-RUNBOOK.md` — runbook do Swarm.
|
||||
- `docs/admin-inventory-ui.md`, `docs/plano-app-desktop-maquinas.md` — detalhes do inventário/agente.
|
||||
|
||||
## Regras de Codigo
|
||||
|
||||
### Tooltips Nativos do Navegador
|
||||
|
||||
**NAO use o atributo `title` em elementos HTML** (button, span, a, div, etc).
|
||||
|
||||
O atributo `title` causa tooltips nativos do navegador que sao inconsistentes visualmente e nao seguem o design system da aplicacao.
|
||||
|
||||
```tsx
|
||||
// ERRADO - causa tooltip nativo do navegador
|
||||
<button title="Remover item">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
|
||||
// CORRETO - sem tooltip nativo
|
||||
<button>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
|
||||
// CORRETO - se precisar de tooltip, use o componente Tooltip do shadcn/ui
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Remover item</TooltipContent>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
**Excecoes:**
|
||||
- Props `title` de componentes customizados (CardTitle, DialogTitle, etc) sao permitidas pois nao geram tooltips nativos.
|
||||
|
||||
### Acessibilidade
|
||||
|
||||
Para manter acessibilidade em botoes apenas com icone, prefira usar `aria-label`:
|
||||
|
||||
```tsx
|
||||
<button aria-label="Remover item">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
_Última atualização: 10/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._
|
||||
_Última atualização: 15/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._
|
||||
|
|
|
|||
|
|
@ -2661,7 +2661,7 @@ export const setChecklistItemAnswer = mutation({
|
|||
ticketId: v.id("tickets"),
|
||||
actorId: v.id("users"),
|
||||
itemId: v.string(),
|
||||
answer: v.string(),
|
||||
answer: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, { ticketId, actorId, itemId, answer }) => {
|
||||
const ticket = await ctx.db.get(ticketId);
|
||||
|
|
@ -2683,7 +2683,7 @@ export const setChecklistItemAnswer = mutation({
|
|||
}
|
||||
|
||||
const now = Date.now();
|
||||
const normalizedAnswer = answer.trim();
|
||||
const normalizedAnswer = answer?.trim() ?? "";
|
||||
const isDone = normalizedAnswer.length > 0;
|
||||
|
||||
const nextChecklist = checklist.map((it) => {
|
||||
|
|
|
|||
|
|
@ -300,10 +300,23 @@ export const updateAvatar = mutation({
|
|||
return { status: "not_found" }
|
||||
}
|
||||
|
||||
// Atualiza o avatar do usuário
|
||||
// Atualiza o avatar do usuário - usa undefined para remover o campo
|
||||
const normalizedAvatarUrl = avatarUrl ?? undefined
|
||||
await ctx.db.patch(user._id, { avatarUrl: normalizedAvatarUrl })
|
||||
|
||||
// Cria snapshot base sem avatarUrl se for undefined
|
||||
// Isso garante que o campo seja realmente removido do snapshot
|
||||
const baseSnapshot: { name: string; email: string; avatarUrl?: string; teams?: string[] } = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
}
|
||||
if (normalizedAvatarUrl !== undefined) {
|
||||
baseSnapshot.avatarUrl = normalizedAvatarUrl
|
||||
}
|
||||
if (user.teams && user.teams.length > 0) {
|
||||
baseSnapshot.teams = user.teams
|
||||
}
|
||||
|
||||
// Atualiza snapshots em comentários
|
||||
const comments = await ctx.db
|
||||
.query("ticketComments")
|
||||
|
|
@ -311,15 +324,9 @@ export const updateAvatar = mutation({
|
|||
.take(10000)
|
||||
|
||||
if (comments.length > 0) {
|
||||
const authorSnapshot = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: normalizedAvatarUrl,
|
||||
teams: user.teams ?? undefined,
|
||||
}
|
||||
await Promise.all(
|
||||
comments.map(async (comment) => {
|
||||
await ctx.db.patch(comment._id, { authorSnapshot })
|
||||
await ctx.db.patch(comment._id, { authorSnapshot: baseSnapshot })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -331,14 +338,8 @@ export const updateAvatar = mutation({
|
|||
.take(10000)
|
||||
|
||||
if (requesterTickets.length > 0) {
|
||||
const requesterSnapshot = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: normalizedAvatarUrl,
|
||||
teams: user.teams ?? undefined,
|
||||
}
|
||||
for (const t of requesterTickets) {
|
||||
await ctx.db.patch(t._id, { requesterSnapshot })
|
||||
await ctx.db.patch(t._id, { requesterSnapshot: baseSnapshot })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -349,14 +350,8 @@ export const updateAvatar = mutation({
|
|||
.take(10000)
|
||||
|
||||
if (assigneeTickets.length > 0) {
|
||||
const assigneeSnapshot = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: normalizedAvatarUrl,
|
||||
teams: user.teams ?? undefined,
|
||||
}
|
||||
for (const t of assigneeTickets) {
|
||||
await ctx.db.patch(t._id, { assigneeSnapshot })
|
||||
await ctx.db.patch(t._id, { assigneeSnapshot: baseSnapshot })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,3 +137,45 @@ export async function DELETE() {
|
|||
return NextResponse.json({ error: "Erro interno do servidor" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH - Força sincronização do avatar atual do Prisma para o Convex
|
||||
* Útil quando a sincronização automática falhou
|
||||
*/
|
||||
export async function PATCH() {
|
||||
try {
|
||||
const session = await getServerSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
// Busca o avatar atual no Prisma
|
||||
const user = await prisma.authUser.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { avatarUrl: true },
|
||||
})
|
||||
|
||||
// Sincroniza com o Convex
|
||||
const convex = createConvexClient()
|
||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
||||
const result = await convex.mutation(api.users.updateAvatar, {
|
||||
tenantId,
|
||||
email: session.user.email,
|
||||
avatarUrl: user?.avatarUrl ?? null,
|
||||
})
|
||||
|
||||
console.log("[profile/avatar] Sincronização forçada:", result)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Avatar sincronizado com sucesso",
|
||||
avatarUrl: user?.avatarUrl ?? null,
|
||||
convexResult: result,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[profile/avatar] Erro na sincronização:", error)
|
||||
return NextResponse.json({ error: "Erro ao sincronizar avatar" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4089,7 +4089,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
size="sm"
|
||||
className="h-7 border border-transparent px-2 text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
|
||||
onClick={() => handleCopyRemoteIdentifier(entry.identifier)}
|
||||
title="Copiar ID"
|
||||
aria-label="Copiar ID"
|
||||
>
|
||||
<ClipboardCopy className="size-3.5" />
|
||||
|
|
@ -4109,7 +4108,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
size="sm"
|
||||
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
|
||||
onClick={() => handleCopyRemoteCredential(entry.username, "Usuário do acesso remoto")}
|
||||
title="Copiar usuário"
|
||||
aria-label="Copiar usuário"
|
||||
>
|
||||
<ClipboardCopy className="size-3.5" />
|
||||
|
|
@ -4119,7 +4117,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
{entry.password ? (
|
||||
<div className="inline-flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">Senha</span>
|
||||
<code className="rounded-md border border-slate-200 bg-white px-2.5 py-1 font-mono text-sm text-slate-700">
|
||||
<code className="rounded-md border border-slate-200 bg-white px-2.5 py-1 font-mono text-sm font-semibold text-neutral-800">
|
||||
{secretVisible ? entry.password : "••••••••"}
|
||||
</code>
|
||||
<Button
|
||||
|
|
@ -4127,7 +4125,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
size="sm"
|
||||
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
|
||||
onClick={() => toggleRemoteSecret(entry.clientId)}
|
||||
title={secretVisible ? "Ocultar senha" : "Mostrar senha"}
|
||||
aria-label={secretVisible ? "Ocultar senha" : "Mostrar senha"}
|
||||
>
|
||||
{secretVisible ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
||||
|
|
@ -4137,7 +4134,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
|||
size="sm"
|
||||
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
|
||||
onClick={() => handleCopyRemoteCredential(entry.password, "Senha do acesso remoto")}
|
||||
title="Copiar senha"
|
||||
aria-label="Copiar senha"
|
||||
>
|
||||
<ClipboardCopy className="size-3.5" />
|
||||
|
|
|
|||
|
|
@ -824,7 +824,6 @@ export function AutomationEditorDialog({
|
|||
size="icon"
|
||||
onClick={() => handleRemoveCondition(c.id)}
|
||||
className="mt-6 h-8 w-8 text-slate-500 hover:bg-red-50 hover:text-red-700"
|
||||
title="Remover"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
|
|
@ -1074,7 +1073,6 @@ export function AutomationEditorDialog({
|
|||
size="icon"
|
||||
onClick={() => handleRemoveAction(a.id)}
|
||||
className="mt-6 h-8 w-8 text-slate-500 hover:bg-red-50 hover:text-red-700"
|
||||
title="Remover"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -72,9 +72,9 @@ export function ChatSessionItem({ session, isActive, onClick }: ChatSessionItemP
|
|||
{/* Indicador online/offline */}
|
||||
{session.machineOnline !== undefined && (
|
||||
session.machineOnline ? (
|
||||
<span className="size-2 rounded-full bg-emerald-500" title="Online" />
|
||||
<span className="size-2 rounded-full bg-emerald-500" />
|
||||
) : (
|
||||
<WifiOff className="size-3 text-slate-400" title="Offline" />
|
||||
<WifiOff className="size-3 text-slate-400" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -68,7 +68,6 @@ export function ChatSessionList({
|
|||
<button
|
||||
onClick={onMinimize}
|
||||
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
||||
title="Minimizar"
|
||||
>
|
||||
<svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
|
|
@ -77,7 +76,6 @@ export function ChatSessionList({
|
|||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
||||
title="Fechar"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -219,7 +219,6 @@ function MessageAttachment({ attachment }: { attachment: ChatAttachment }) {
|
|||
<button
|
||||
onClick={handleView}
|
||||
className="flex size-6 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
|
||||
title="Visualizar"
|
||||
>
|
||||
<Eye className="size-3.5 text-white" />
|
||||
</button>
|
||||
|
|
@ -227,7 +226,6 @@ function MessageAttachment({ attachment }: { attachment: ChatAttachment }) {
|
|||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className="flex size-6 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
|
||||
title="Baixar"
|
||||
>
|
||||
{downloading ? (
|
||||
<Spinner className="size-3 text-white" />
|
||||
|
|
@ -249,7 +247,6 @@ function MessageAttachment({ attachment }: { attachment: ChatAttachment }) {
|
|||
<button
|
||||
onClick={handleView}
|
||||
className="rounded p-0.5 hover:bg-slate-200"
|
||||
title="Visualizar"
|
||||
>
|
||||
<Eye className="size-3 text-slate-400" />
|
||||
</button>
|
||||
|
|
@ -257,7 +254,6 @@ function MessageAttachment({ attachment }: { attachment: ChatAttachment }) {
|
|||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
className="rounded p-0.5 hover:bg-slate-200"
|
||||
title="Baixar"
|
||||
>
|
||||
{downloading ? (
|
||||
<Spinner className="size-3 text-slate-400" />
|
||||
|
|
@ -721,7 +717,6 @@ export function ChatWidget() {
|
|||
<button
|
||||
onClick={handleBackToList}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-100"
|
||||
title="Voltar para lista"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</button>
|
||||
|
|
@ -785,14 +780,12 @@ export function ChatWidget() {
|
|||
<button
|
||||
onClick={() => setIsMinimized(true)}
|
||||
className="ml-2 rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
||||
title="Minimizar"
|
||||
>
|
||||
<Minimize2 className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
||||
title="Fechar"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
|
|
@ -955,7 +948,6 @@ export function ChatWidget() {
|
|||
className="size-9 text-slate-500 hover:bg-slate-100 hover:text-slate-700"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={attachments.length >= MAX_ATTACHMENTS || isUploading}
|
||||
title="Anexar arquivo"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Spinner className="size-4" />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Loader2, Bell, BellOff, Clock, Mail, Lock } from "lucide-react"
|
||||
import { Bell, BellOff, Clock, Mail, Lock, Loader2 } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
|
@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Separator } from "@/components/ui/separator"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface NotificationType {
|
||||
|
|
@ -143,8 +144,81 @@ export function NotificationPreferencesForm({ isPortal = false }: NotificationPr
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<div className="space-y-6">
|
||||
{/* Skeleton do card de e-mail */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</div>
|
||||
<Skeleton className="mt-1 h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-11 rounded-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Skeleton do card de tipos de notificacao */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<Skeleton className="h-5 w-44" />
|
||||
</div>
|
||||
<Skeleton className="mt-1 h-4 w-80" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Grupo 1 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="mt-1 h-3 w-64" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-lg border border-slate-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-11 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grupo 2 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="mt-1 h-3 w-56" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-lg border border-slate-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-11 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Skeleton do botao salvar */}
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-10 w-40 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { FormEvent, useMemo, useRef, useState } from "react"
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import dynamic from "next/dynamic"
|
||||
|
|
@ -330,6 +330,7 @@ function ProfileEditCard({
|
|||
avatarUrl: string | null
|
||||
initials: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [editName, setEditName] = useState(name)
|
||||
const [editEmail, setEditEmail] = useState(email)
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
|
|
@ -341,6 +342,11 @@ function ProfileEditCard({
|
|||
const [pendingRemoveAvatar, setPendingRemoveAvatar] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Sincroniza localAvatarUrl quando a prop avatarUrl muda (ex: sessao atualizada)
|
||||
useEffect(() => {
|
||||
setLocalAvatarUrl(avatarUrl)
|
||||
}, [avatarUrl])
|
||||
|
||||
// URL de exibição: preview pendente > URL atual (se não marcado para remoção)
|
||||
const displayAvatarUrl = pendingAvatarPreview ?? (pendingRemoveAvatar ? null : localAvatarUrl)
|
||||
|
||||
|
|
@ -479,6 +485,9 @@ function ProfileEditCard({
|
|||
setNewPassword("")
|
||||
setConfirmPassword("")
|
||||
toast.success("Dados atualizados com sucesso!")
|
||||
|
||||
// Atualiza a sessao para refletir mudancas (avatar, etc)
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error("Falha ao atualizar perfil", error)
|
||||
toast.error(error instanceof Error ? error.message : "Não foi possível atualizar o perfil.")
|
||||
|
|
@ -512,25 +521,23 @@ function ProfileEditCard({
|
|||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="size-5 text-white animate-spin" />
|
||||
<Loader2 className="size-4 text-white animate-spin" />
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-8 rounded-full bg-white/20 hover:bg-white/30 transition-colors"
|
||||
className="flex items-center justify-center size-6 rounded-full bg-white/20 hover:bg-white/30 transition-colors"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="Alterar foto"
|
||||
>
|
||||
<Camera className="size-4 text-white" />
|
||||
<Camera className="size-3 text-white" />
|
||||
</button>
|
||||
{(displayAvatarUrl || pendingAvatarFile) && !pendingRemoveAvatar && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-8 rounded-full bg-red-500/80 hover:bg-red-500 transition-colors"
|
||||
className="flex items-center justify-center size-6 rounded-full bg-red-500/80 hover:bg-red-500 transition-colors"
|
||||
onClick={handleRemoveAvatarClick}
|
||||
title="Remover foto"
|
||||
>
|
||||
<Trash2 className="size-4 text-white" />
|
||||
<Trash2 className="size-3 text-white" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -939,7 +939,6 @@ export function CloseTicketDialog({
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title="Limpar mensagem"
|
||||
aria-label="Limpar mensagem"
|
||||
onClick={() => {
|
||||
setMessage("")
|
||||
|
|
|
|||
|
|
@ -1416,7 +1416,6 @@ export function NewTicketDialog({
|
|||
onClick={() =>
|
||||
setAppliedChecklistTemplateIds((prev) => prev.filter((id) => id !== String(tpl.id)))
|
||||
}
|
||||
title="Remover template"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
|
@ -1482,7 +1481,6 @@ export function NewTicketDialog({
|
|||
size="icon"
|
||||
className="h-9 w-9 text-slate-500 hover:bg-red-50 hover:text-red-700"
|
||||
onClick={() => setManualChecklist((prev) => prev.filter((row) => row.id !== item.id))}
|
||||
title="Remover"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import { Checkbox } from "@/components/ui/checkbox"
|
|||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
|
|
@ -391,38 +390,39 @@ export function TicketChecklistCard({
|
|||
)}
|
||||
|
||||
{isQuestion && options.length > 0 && (
|
||||
<RadioGroup
|
||||
value={item.answer ?? ""}
|
||||
onValueChange={async (value) => {
|
||||
if (!actorId || !canToggle) return
|
||||
try {
|
||||
await setChecklistItemAnswer({
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
actorId,
|
||||
itemId: item.id,
|
||||
answer: value,
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Falha ao responder pergunta.")
|
||||
}
|
||||
}}
|
||||
disabled={!canToggle || !actorId}
|
||||
className="flex flex-wrap items-center gap-4"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-colors ${
|
||||
item.answer === option
|
||||
? "border-neutral-900 bg-neutral-900 text-white"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<RadioGroupItem value={option} className="sr-only" />
|
||||
{option}
|
||||
</label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{options.map((option) => {
|
||||
const isSelected = item.answer === option
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
disabled={!canToggle || !actorId}
|
||||
onClick={async () => {
|
||||
if (!actorId || !canToggle) return
|
||||
try {
|
||||
await setChecklistItemAnswer({
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
actorId,
|
||||
itemId: item.id,
|
||||
// Toggle: se já está selecionado, limpa; senão, seleciona
|
||||
answer: isSelected ? undefined : option,
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Falha ao responder pergunta.")
|
||||
}
|
||||
}}
|
||||
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
|
||||
isSelected
|
||||
? "border-neutral-900 bg-neutral-900 text-white"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50"
|
||||
} ${!canToggle || !actorId ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
|
@ -476,7 +476,6 @@ export function TicketChecklistCard({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-slate-500 hover:bg-red-50 hover:text-red-700"
|
||||
title="Remover"
|
||||
onClick={() => setDeleteTarget(item)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react"
|
||||
import { Download, FileCode, FileIcon, Image as ImageIcon, PencilLine, Trash2, X, ClipboardCopy } from "lucide-react"
|
||||
import { IconMessage, IconFileText, IconPencil } from "@tabler/icons-react"
|
||||
import { Download, FileCode, FileIcon, Image as ImageIcon, Trash2, X, ClipboardCopy } from "lucide-react"
|
||||
import { useAction, useMutation, useQuery } from "convex/react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
@ -30,13 +30,15 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
|||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface TicketCommentsProps {
|
||||
ticket: TicketWithDetails
|
||||
}
|
||||
|
||||
const badgeInternal = "gap-1 rounded-full border border-slate-300 bg-neutral-900 px-2 py-0.5 text-xs font-semibold tracking-wide text-white"
|
||||
const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||
const badgeInternal = "rounded-full border border-slate-300 bg-neutral-900 px-2.5 py-0.5 text-xs font-semibold text-white"
|
||||
const badgePublic = "rounded-full border border-[#00d6eb]/40 bg-[#00e8ff]/15 px-2.5 py-0.5 text-xs font-semibold text-neutral-900"
|
||||
const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-slate-400"
|
||||
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"
|
||||
|
||||
|
|
@ -45,6 +47,64 @@ const COMMENT_ATTACHMENT_MAX_FILE_SIZE = 5 * 1024 * 1024
|
|||
|
||||
type CommentsOrder = "descending" | "ascending"
|
||||
|
||||
// Detecta e parseia comentários automáticos de sistema
|
||||
interface SystemCommentData {
|
||||
type: "assignee_change" | "status_change" | "priority_change" | "queue_change"
|
||||
field: string
|
||||
from: string
|
||||
to: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
function parseSystemComment(html: string): SystemCommentData | null {
|
||||
// Padrões de comentários automáticos
|
||||
const patterns = [
|
||||
{ regex: /<strong>Responsável atualizado:<\/strong>\s*([^→<]+)\s*→\s*([^<]+)/i, type: "assignee_change" as const, field: "Responsável" },
|
||||
{ regex: /<strong>Status atualizado:<\/strong>\s*([^→<]+)\s*→\s*([^<]+)/i, type: "status_change" as const, field: "Status" },
|
||||
{ regex: /<strong>Prioridade atualizada:<\/strong>\s*([^→<]+)\s*→\s*([^<]+)/i, type: "priority_change" as const, field: "Prioridade" },
|
||||
{ regex: /<strong>Fila atualizada:<\/strong>\s*([^→<]+)\s*→\s*([^<]+)/i, type: "queue_change" as const, field: "Fila" },
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern.regex)
|
||||
if (match) {
|
||||
// Extrair motivo se existir
|
||||
const reasonMatch = html.match(/<strong>Motivo da troca:<\/strong><\/p>\s*<p>([^<]+)/i)
|
||||
return {
|
||||
type: pattern.type,
|
||||
field: pattern.field,
|
||||
from: match[1].trim(),
|
||||
to: match[2].trim(),
|
||||
reason: reasonMatch ? reasonMatch[1].trim() : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function SystemCommentContent({ data }: { data: SystemCommentData }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-neutral-600">{data.field}:</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-slate-50 px-2 py-0.5 text-sm text-neutral-600">
|
||||
{data.from}
|
||||
</span>
|
||||
<span className="text-neutral-400">→</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-neutral-900/10 px-2 py-0.5 text-sm font-medium text-neutral-900">
|
||||
{data.to}
|
||||
</span>
|
||||
</div>
|
||||
{data.reason && (
|
||||
<p className="text-sm text-neutral-500">
|
||||
<span className="font-medium">Motivo:</span> {data.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||
const { convexUserId, isStaff, role } = useAuth()
|
||||
const normalizedRole = role ?? null
|
||||
|
|
@ -422,54 +482,57 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
const hasBody = bodyPlain.length > 0 || isEditing
|
||||
const isInternal = comment.visibility === "INTERNAL" && canSeeInternalComments
|
||||
const isPublic = comment.visibility === "PUBLIC"
|
||||
const containerClass = isPublic
|
||||
? "group/comment flex gap-3 rounded-2xl border border-amber-200/80 bg-amber-50/80 px-3 py-3 shadow-[0_0_0_1px_rgba(217,119,6,0.15)]"
|
||||
: "group/comment flex gap-3 rounded-2xl border border-slate-200 bg-white px-3 py-3"
|
||||
const bodyClass = isPublic
|
||||
? "relative break-words rounded-xl border border-amber-200/80 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700 shadow-[0_0_0_1px_rgba(217,119,6,0.08)]"
|
||||
: "relative break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700"
|
||||
const bodyEditButtonClass = isPublic
|
||||
? "absolute right-3 top-1/2 inline-flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-amber-200 bg-white text-amber-700 opacity-0 transition hover:border-amber-500 hover:text-amber-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30 group-hover/comment:opacity-100"
|
||||
: "absolute right-3 top-1/2 inline-flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 group-hover/comment:opacity-100"
|
||||
const addContentButtonClass = isPublic
|
||||
? "inline-flex items-center gap-2 text-sm font-medium text-amber-700 transition hover:text-amber-900"
|
||||
: "inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition hover:text-neutral-900"
|
||||
|
||||
return (
|
||||
<div key={comment.id} className={containerClass}>
|
||||
<Avatar className="size-9 border border-slate-200">
|
||||
<AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-semibold text-neutral-900">{comment.author.name}</span>
|
||||
{comment.visibility === "INTERNAL" && canSeeInternalComments ? (
|
||||
<Badge className={badgeInternal}>
|
||||
<IconLock className="size-3 text-[#00e8ff]" /> Interno
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className="text-xs text-neutral-500">
|
||||
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
||||
</span>
|
||||
<div
|
||||
key={comment.id}
|
||||
className={cn(
|
||||
"group/comment overflow-hidden rounded-2xl border",
|
||||
isPublic
|
||||
? "border-[#00d6eb]/30 bg-[#00e8ff]/[0.08]"
|
||||
: "border-slate-200 bg-white"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-between gap-3 border-b px-4 py-3",
|
||||
isPublic ? "border-[#00d6eb]/20" : "border-slate-100"
|
||||
)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-10 border border-slate-200">
|
||||
<AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} />
|
||||
<AvatarFallback className="bg-slate-100 text-sm font-medium text-neutral-700">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-neutral-900">{comment.author.name}</span>
|
||||
{isInternal ? (
|
||||
<Badge className={badgeInternal}>Interno</Badge>
|
||||
) : isPublic ? (
|
||||
<Badge className={badgePublic}>Público</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isInternal ? (
|
||||
<span className="text-xs font-semibold tracking-wide text-amber-700/80">
|
||||
Comentário interno — visível apenas para administradores e agentes
|
||||
</span>
|
||||
) : comment.visibility === "PUBLIC" ? (
|
||||
<span className="text-xs font-semibold tracking-wide text-amber-700/80">
|
||||
Comentário visível para o cliente
|
||||
</span>
|
||||
) : null}
|
||||
{isEditing ? (
|
||||
<div
|
||||
className={
|
||||
isInternal
|
||||
? "rounded-xl border border-amber-200/80 bg-white px-3 py-2 shadow-[0_0_0_1px_rgba(217,119,6,0.08)]"
|
||||
: "rounded-xl border border-slate-200 bg-white px-3 py-2"
|
||||
}
|
||||
{canEdit && !isEditing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEditingComment(commentId, storedBody)}
|
||||
className="inline-flex size-8 items-center justify-center rounded-lg border border-slate-200 bg-white text-neutral-600 opacity-0 transition hover:bg-slate-50 hover:text-neutral-900 group-hover/comment:opacity-100"
|
||||
aria-label="Editar comentário"
|
||||
>
|
||||
<IconPencil className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Conteúdo */}
|
||||
<div className="px-4 py-3">
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<RichTextEditor
|
||||
value={editingComment?.value ?? ""}
|
||||
onChange={(next) =>
|
||||
|
|
@ -478,12 +541,14 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
disabled={savingCommentId === commentId}
|
||||
placeholder="Edite o comentário..."
|
||||
ticketMention={{ enabled: allowTicketMentions }}
|
||||
className="rounded-xl border border-slate-200"
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100"
|
||||
size="sm"
|
||||
className="rounded-lg border border-slate-200 text-sm font-medium text-neutral-700 hover:bg-slate-100"
|
||||
onClick={cancelEditingComment}
|
||||
disabled={savingCommentId === commentId}
|
||||
>
|
||||
|
|
@ -491,6 +556,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={submitButtonClass}
|
||||
onClick={() => saveEditedComment(commentId, storedBody)}
|
||||
disabled={savingCommentId === commentId}
|
||||
|
|
@ -500,39 +566,27 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
</div>
|
||||
</div>
|
||||
) : hasBody ? (
|
||||
<div className={bodyClass}>
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEditingComment(commentId, storedBody)}
|
||||
className={bodyEditButtonClass}
|
||||
aria-label="Editar comentário"
|
||||
>
|
||||
<PencilLine className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
<RichTextContent html={storedBody} />
|
||||
</div>
|
||||
) : canEdit ? (
|
||||
<div
|
||||
className={
|
||||
isInternal
|
||||
? "rounded-xl border border-dashed border-amber-300 bg-amber-50/60 px-3 py-2 text-sm text-amber-700"
|
||||
: "rounded-xl border border-dashed border-slate-300 bg-white/60 px-3 py-2 text-sm text-neutral-500"
|
||||
(() => {
|
||||
const systemData = parseSystemComment(storedBody)
|
||||
if (systemData) {
|
||||
return <SystemCommentContent data={systemData} />
|
||||
}
|
||||
return <RichTextContent html={storedBody} className="text-sm leading-relaxed text-neutral-700" />
|
||||
})()
|
||||
) : canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEditingComment(commentId, storedBody)}
|
||||
className="flex w-full items-center gap-2 rounded-xl border border-dashed border-slate-200 bg-slate-50/50 px-3 py-2 text-sm text-neutral-500 transition hover:border-slate-300 hover:text-neutral-700"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEditingComment(commentId, storedBody)}
|
||||
className={addContentButtonClass}
|
||||
>
|
||||
<PencilLine className="size-4" />
|
||||
Adicionar conteúdo ao comentário
|
||||
</button>
|
||||
</div>
|
||||
<IconPencil className="size-4" />
|
||||
Adicionar conteúdo ao comentário
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* Anexos */}
|
||||
{comment.attachments?.length ? (
|
||||
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
||||
<div className="mt-4 grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
||||
{comment.attachments.map((attachment) => (
|
||||
<CommentAttachmentCard
|
||||
key={attachment.id}
|
||||
|
|
@ -551,7 +605,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
})
|
||||
)}
|
||||
{!canComment && (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-600">
|
||||
Atribua um responsável ao chamado para que a equipe técnica possa comentar.
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,25 @@
|
|||
import { useMemo } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { MonitorSmartphone } from "lucide-react"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getSlaDisplayStatus, getSlaDueDate, type SlaDisplayStatus } from "@/lib/sla-utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { MonitorSmartphone } from "lucide-react"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { useTicketRemoteAccess } from "@/hooks/use-ticket-remote-access"
|
||||
import { PrioritySelect } from "@/components/tickets/priority-select"
|
||||
import { StatusSelect } from "@/components/tickets/status-select"
|
||||
|
||||
interface TicketDetailsPanelProps {
|
||||
ticket: TicketWithDetails
|
||||
|
|
@ -84,6 +93,7 @@ type SummaryChipConfig = {
|
|||
}
|
||||
|
||||
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||
const { convexUserId, isStaff } = useAuth()
|
||||
const {
|
||||
canShowRemoteAccess,
|
||||
primaryRemoteAccess,
|
||||
|
|
@ -91,6 +101,91 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
hostname: remoteHostname,
|
||||
} = useTicketRemoteAccess(ticket)
|
||||
|
||||
const canEdit = isStaff && !!convexUserId
|
||||
|
||||
// Mutations para edição
|
||||
const updateStatus = useMutation(api.tickets.updateStatus)
|
||||
const updatePriority = useMutation(api.tickets.updatePriority)
|
||||
const changeQueue = useMutation(api.tickets.changeQueue)
|
||||
const changeAssignee = useMutation(api.tickets.changeAssignee)
|
||||
|
||||
// Queries para opções (só carrega se usuário é staff e pode editar)
|
||||
const queues = useQuery(
|
||||
api.queues.listForStaff,
|
||||
canEdit && convexUserId ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||
)
|
||||
const agents = useQuery(
|
||||
api.users.listAgents,
|
||||
canEdit ? { tenantId: ticket.tenantId } : "skip"
|
||||
)
|
||||
|
||||
// Estados para popovers
|
||||
const [statusOpen, setStatusOpen] = useState(false)
|
||||
const [priorityOpen, setPriorityOpen] = useState(false)
|
||||
const [queueOpen, setQueueOpen] = useState(false)
|
||||
const [assigneeOpen, setAssigneeOpen] = useState(false)
|
||||
|
||||
// Handlers de edição
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
if (!convexUserId) return
|
||||
try {
|
||||
await updateStatus({
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
actorId: convexUserId as Id<"users">,
|
||||
status: newStatus,
|
||||
})
|
||||
setStatusOpen(false)
|
||||
toast.success("Status atualizado")
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Erro ao atualizar status")
|
||||
}
|
||||
}
|
||||
|
||||
const handlePriorityChange = async (newPriority: string) => {
|
||||
if (!convexUserId) return
|
||||
try {
|
||||
await updatePriority({
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
actorId: convexUserId as Id<"users">,
|
||||
priority: newPriority,
|
||||
})
|
||||
setPriorityOpen(false)
|
||||
toast.success("Prioridade atualizada")
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Erro ao atualizar prioridade")
|
||||
}
|
||||
}
|
||||
|
||||
const handleQueueChange = async (newQueue: string) => {
|
||||
if (!convexUserId) return
|
||||
try {
|
||||
await changeQueue({
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
actorId: convexUserId as Id<"users">,
|
||||
queueId: newQueue === "__none__" ? undefined : (newQueue as Id<"queues">),
|
||||
})
|
||||
setQueueOpen(false)
|
||||
toast.success("Fila atualizada")
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Erro ao atualizar fila")
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssigneeChange = async (newAssignee: string) => {
|
||||
if (!convexUserId) return
|
||||
try {
|
||||
await changeAssignee({
|
||||
ticketId: ticket.id as Id<"tickets">,
|
||||
actorId: convexUserId as Id<"users">,
|
||||
assigneeId: newAssignee === "__none__" ? undefined : (newAssignee as Id<"users">),
|
||||
})
|
||||
setAssigneeOpen(false)
|
||||
toast.success("Responsável atualizado")
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Erro ao atualizar responsável")
|
||||
}
|
||||
}
|
||||
|
||||
const isAvulso = Boolean(ticket.company?.isAvulso)
|
||||
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
|
||||
const responseStatus = getSlaDisplayStatus(ticket, "response")
|
||||
|
|
@ -177,9 +272,153 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Resumo</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{summaryChips.map(({ key, label, value, tone, labelClassName }) => (
|
||||
<SummaryChip key={key} label={label} value={value} tone={tone} labelClassName={labelClassName} />
|
||||
))}
|
||||
{/* Fila - editável */}
|
||||
{canEdit ? (
|
||||
<Popover open={queueOpen} onOpenChange={setQueueOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className="text-left w-full">
|
||||
<SummaryChip
|
||||
label="Fila"
|
||||
value={ticket.queue ?? "Sem fila"}
|
||||
tone={ticket.queue ? "default" : "muted"}
|
||||
editable
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start" sideOffset={4}>
|
||||
<div className="p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide">Alterar fila</p>
|
||||
<Select
|
||||
value={queues?.find((q) => q.name === ticket.queue)?.id ?? "__none__"}
|
||||
onValueChange={handleQueueChange}
|
||||
>
|
||||
<SelectTrigger className="h-10 bg-white">
|
||||
<SelectValue placeholder="Selecione a fila" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Sem fila</SelectItem>
|
||||
{queues?.map((q) => (
|
||||
<SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<SummaryChip label="Fila" value={ticket.queue ?? "Sem fila"} tone={ticket.queue ? "default" : "muted"} />
|
||||
)}
|
||||
|
||||
{/* Empresa - não editável */}
|
||||
<SummaryChip
|
||||
label="Empresa"
|
||||
value={companyLabel}
|
||||
tone={isAvulso ? "warning" : "default"}
|
||||
/>
|
||||
|
||||
{/* Status - editável */}
|
||||
{canEdit ? (
|
||||
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className="text-left w-full">
|
||||
<SummaryChip
|
||||
label="Status"
|
||||
value={getTicketStatusLabel(ticket.status) ?? ticket.status}
|
||||
tone={getTicketStatusSummaryTone(ticket.status) as SummaryTone}
|
||||
editable
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start" sideOffset={4}>
|
||||
<div className="p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide">Alterar status</p>
|
||||
<StatusSelect value={ticket.status} onValueChange={handleStatusChange} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<SummaryChip
|
||||
label="Status"
|
||||
value={getTicketStatusLabel(ticket.status) ?? ticket.status}
|
||||
tone={getTicketStatusSummaryTone(ticket.status) as SummaryTone}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Prioridade - editável */}
|
||||
{canEdit ? (
|
||||
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className="text-left w-full">
|
||||
<SummaryChip
|
||||
label="Prioridade"
|
||||
value={priorityLabel[ticket.priority] ?? ticket.priority}
|
||||
tone={priorityTone[ticket.priority] ?? "default"}
|
||||
editable
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start" sideOffset={4}>
|
||||
<div className="p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide">Alterar prioridade</p>
|
||||
<PrioritySelect value={ticket.priority} onValueChange={handlePriorityChange} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<SummaryChip
|
||||
label="Prioridade"
|
||||
value={priorityLabel[ticket.priority] ?? ticket.priority}
|
||||
tone={priorityTone[ticket.priority] ?? "default"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Responsável - editável */}
|
||||
{canEdit ? (
|
||||
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className="text-left w-full">
|
||||
<SummaryChip
|
||||
label="Responsável"
|
||||
value={ticket.assignee?.name ?? "Não atribuído"}
|
||||
tone={ticket.assignee ? "default" : "muted"}
|
||||
editable
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start" sideOffset={4}>
|
||||
<div className="p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide">Alterar responsavel</p>
|
||||
<Select value={ticket.assignee?.id ?? "__none__"} onValueChange={handleAssigneeChange}>
|
||||
<SelectTrigger className="h-10 bg-white">
|
||||
<SelectValue placeholder="Selecione o responsavel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Nao atribuido</SelectItem>
|
||||
{agents?.map((a) => (
|
||||
<SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<SummaryChip
|
||||
label="Responsável"
|
||||
value={ticket.assignee?.name ?? "Não atribuído"}
|
||||
tone={ticket.assignee ? "default" : "muted"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fluxo - não editável */}
|
||||
{ticket.formTemplateLabel && (
|
||||
<SummaryChip
|
||||
label="Fluxo"
|
||||
value={ticket.formTemplateLabel}
|
||||
tone="primary"
|
||||
labelClassName="text-white"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -350,11 +589,13 @@ function SummaryChip({
|
|||
value,
|
||||
tone = "default",
|
||||
labelClassName,
|
||||
editable = false,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
tone?: SummaryTone
|
||||
labelClassName?: string
|
||||
editable?: boolean
|
||||
}) {
|
||||
const toneClasses: Record<SummaryTone, string> = {
|
||||
default: "border-slate-200 bg-white text-neutral-900",
|
||||
|
|
@ -367,7 +608,13 @@ function SummaryChip({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-xl border px-3 py-2 shadow-sm", toneClasses[tone])}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-2 shadow-sm transition-all",
|
||||
toneClasses[tone],
|
||||
editable && "cursor-pointer hover:ring-2 hover:ring-slate-300 hover:border-slate-400"
|
||||
)}
|
||||
>
|
||||
<p className={cn("text-[11px] font-semibold uppercase tracking-wide text-neutral-500", labelClassName)}>{label}</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-current">{value}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -87,7 +87,21 @@ export function useAuth() {
|
|||
return useContext(AuthContext)
|
||||
}
|
||||
|
||||
export const { signIn, signOut, useSession } = authClient
|
||||
export const { signIn, signOut, useSession, $Infer, getSession, ...authClientRest } = authClient
|
||||
|
||||
/**
|
||||
* Força atualização da sessão (útil após alterar dados do usuário como avatar)
|
||||
* Retorna a nova sessão ou null
|
||||
*/
|
||||
export async function refreshSession(): Promise<AppSession | null> {
|
||||
try {
|
||||
// getSession do Better Auth busca a sessão atualizada do servidor
|
||||
const result = await getSession({ fetchOptions: { cache: "no-store" } })
|
||||
return result?.data ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const devBypass = process.env.NODE_ENV !== "production" && process.env.NEXT_PUBLIC_DEV_BYPASS_AUTH === "1"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue