Implement company provisioning codes and session tweaks
This commit is contained in:
parent
0fb9bf59b2
commit
2cba553efa
28 changed files with 1407 additions and 534 deletions
125
src/components/portal/portal-profile-settings.tsx
Normal file
125
src/components/portal/portal-profile-settings.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"use client"
|
||||
|
||||
import { FormEvent, useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface PortalProfileSettingsProps {
|
||||
initialEmail: string
|
||||
}
|
||||
|
||||
export function PortalProfileSettings({ initialEmail }: PortalProfileSettingsProps) {
|
||||
const [email, setEmail] = useState(initialEmail)
|
||||
const [referenceEmail, setReferenceEmail] = useState(initialEmail)
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
const normalizedEmail = email.trim().toLowerCase()
|
||||
const emailChanged = normalizedEmail !== referenceEmail.trim().toLowerCase()
|
||||
const passwordChanged = newPassword.length > 0 || confirmPassword.length > 0
|
||||
return emailChanged || passwordChanged
|
||||
}, [email, referenceEmail, newPassword, confirmPassword])
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!hasChanges) {
|
||||
toast.info("Nenhuma alteração a salvar.")
|
||||
return
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {}
|
||||
const trimmedEmail = email.trim()
|
||||
if (trimmedEmail && trimmedEmail.toLowerCase() !== referenceEmail.trim().toLowerCase()) {
|
||||
payload.email = trimmedEmail
|
||||
}
|
||||
if (newPassword || confirmPassword) {
|
||||
payload.password = { newPassword, confirmPassword }
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/portal/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: "Falha ao atualizar perfil" }))
|
||||
const message = typeof data.error === "string" ? data.error : "Falha ao atualizar perfil"
|
||||
toast.error(message)
|
||||
return
|
||||
}
|
||||
const data = (await res.json().catch(() => null)) as { email?: string } | null
|
||||
if (data?.email) {
|
||||
setEmail(data.email)
|
||||
setReferenceEmail(data.email)
|
||||
}
|
||||
setNewPassword("")
|
||||
setConfirmPassword("")
|
||||
toast.success("Dados atualizados com sucesso!")
|
||||
} catch (error) {
|
||||
console.error("Falha ao atualizar perfil", error)
|
||||
toast.error("Não foi possível atualizar o perfil agora.")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-neutral-900">Meu perfil</CardTitle>
|
||||
<CardDescription className="text-sm text-neutral-600">
|
||||
Ajuste seu e-mail de acesso e defina uma senha para entrar pelo navegador.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium text-neutral-800" htmlFor="profile-email">
|
||||
E-mail
|
||||
</label>
|
||||
<Input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="seuemail@empresa.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium text-neutral-800" htmlFor="profile-password">
|
||||
Nova senha
|
||||
</label>
|
||||
<Input
|
||||
id="profile-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
placeholder="Digite a nova senha"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
placeholder="Confirme a nova senha"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Utilize pelo menos 8 caracteres. Deixe os campos em branco caso não queira alterar a senha.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting || !hasChanges}>
|
||||
{isSubmitting ? "Salvando..." : "Salvar alterações"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ interface PortalShellProps {
|
|||
const navItems = [
|
||||
{ label: "Meus chamados", href: "/portal/tickets" },
|
||||
{ label: "Abrir chamado", href: "/portal/tickets/new", icon: PlusCircle },
|
||||
{ label: "Perfil", href: "/portal/profile" },
|
||||
]
|
||||
|
||||
export function PortalShell({ children }: PortalShellProps) {
|
||||
|
|
@ -155,38 +156,6 @@ export function PortalShell({ children }: PortalShellProps) {
|
|||
Recuperando dados do colaborador vinculado...
|
||||
</div>
|
||||
) : null}
|
||||
{!machineContextError && !machineContextLoading && machineContext && !machineContext.assignedUserId ? (
|
||||
<div className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800 shadow-sm">
|
||||
<p className="font-semibold">Debug: m<EFBFBD>quina autenticada sem colaborador vinculado.</p>
|
||||
<p className="mt-1 text-xs text-amber-700">
|
||||
Copie os dados abaixo e compartilhe com o suporte para investigar.
|
||||
</p>
|
||||
<pre className="mt-2 max-h-64 overflow-y-auto rounded-lg bg-white/70 px-3 py-2 text-[11px] leading-tight text-amber-900">
|
||||
{JSON.stringify(machineContext, null, 2)}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const payload = {
|
||||
machineContext,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
try {
|
||||
navigator.clipboard
|
||||
.writeText(JSON.stringify(payload, null, 2))
|
||||
.catch(() => {
|
||||
console.warn("N<>o foi poss<73>vel copiar automaticamente. Selecione o texto manualmente.", payload)
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("Clipboard n<>o suportado. Selecione manualmente.", error)
|
||||
}
|
||||
}}
|
||||
className="mt-2 inline-flex items-center rounded-md border border-amber-300 px-3 py-1 text-xs font-semibold text-amber-800 transition hover:bg-amber-100"
|
||||
>
|
||||
Copiar detalhes
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</main>
|
||||
<footer className="border-t border-slate-200 bg-white/70">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useQuery, useMutation } from "convex/react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useAction, useMutation, useQuery } from "convex/react"
|
||||
import { format, formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { MessageCircle } from "lucide-react"
|
||||
import { Download, FileIcon, MessageCircle, X } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
|
|
@ -14,19 +14,15 @@ import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
|||
import { useAuth } from "@/lib/auth-client"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Dropzone } from "@/components/ui/dropzone"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Dropzone } from "@/components/ui/dropzone"
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
// removed wrong import; RichTextEditor comes from rich-text-editor
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { sanitizeEditorHtml, RichTextEditor } from "@/components/ui/rich-text-editor"
|
||||
|
||||
const statusLabel: Record<TicketWithDetails["status"], string> = {
|
||||
PENDING: "Pendente",
|
||||
AWAITING_ATTENDANCE: "Aguardando atendimento",
|
||||
PAUSED: "Pausado",
|
||||
RESOLVED: "Resolvido",
|
||||
}
|
||||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
|
||||
const priorityLabel: Record<TicketWithDetails["priority"], string> = {
|
||||
LOW: "Baixa",
|
||||
|
|
@ -42,16 +38,6 @@ const priorityTone: Record<TicketWithDetails["priority"], string> = {
|
|||
URGENT: "bg-rose-100 text-rose-700",
|
||||
}
|
||||
|
||||
const timelineLabels: Record<string, string> = {
|
||||
CREATED: "Chamado criado",
|
||||
STATUS_CHANGED: "Status atualizado",
|
||||
ASSIGNEE_CHANGED: "Responsável alterado",
|
||||
COMMENT_ADDED: "Novo comentário",
|
||||
COMMENT_EDITED: "Comentário editado",
|
||||
ATTACHMENT_REMOVED: "Anexo removido",
|
||||
QUEUE_CHANGED: "Fila atualizada",
|
||||
}
|
||||
|
||||
function toHtmlFromText(text: string) {
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
|
|
@ -66,10 +52,21 @@ interface PortalTicketDetailProps {
|
|||
ticketId: string
|
||||
}
|
||||
|
||||
type ClientTimelineEntry = {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
when: Date
|
||||
}
|
||||
|
||||
export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||
const { convexUserId, session, isCustomer } = useAuth()
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
const [comment, setComment] = useState(""); const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
|
||||
const getFileUrl = useAction(api.files.getUrl)
|
||||
const [comment, setComment] = useState("")
|
||||
const [attachments, setAttachments] = useState<
|
||||
Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>
|
||||
>([])
|
||||
|
||||
const ticketRaw = useQuery(
|
||||
api.tickets.getById,
|
||||
|
|
@ -87,6 +84,112 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
return mapTicketWithDetailsFromServer(ticketRaw)
|
||||
}, [ticketRaw])
|
||||
|
||||
const clientTimeline = useMemo(() => {
|
||||
if (!ticket) return []
|
||||
return ticket.timeline
|
||||
.map<ClientTimelineEntry | null>((event) => {
|
||||
const payload = (event.payload ?? {}) as Record<string, unknown>
|
||||
const actorName = typeof payload.actorName === "string" && payload.actorName.trim().length > 0 ? String(payload.actorName).trim() : null
|
||||
|
||||
if (event.type === "CREATED") {
|
||||
const requesterName = typeof payload.requesterName === "string" && payload.requesterName.trim().length > 0
|
||||
? String(payload.requesterName).trim()
|
||||
: null
|
||||
return {
|
||||
id: event.id,
|
||||
title: "Chamado criado",
|
||||
description: requesterName ? `Aberto por ${requesterName}` : "Chamado registrado",
|
||||
when: event.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "QUEUE_CHANGED") {
|
||||
const queueNameRaw =
|
||||
(typeof payload.queueName === "string" && payload.queueName.trim()) ||
|
||||
(typeof payload.toLabel === "string" && payload.toLabel.trim()) ||
|
||||
(typeof payload.to === "string" && payload.to.trim()) ||
|
||||
null
|
||||
if (!queueNameRaw) return null
|
||||
const queueName = queueNameRaw.trim()
|
||||
const description = actorName ? `Fila ${queueName} • por ${actorName}` : `Fila ${queueName}`
|
||||
return {
|
||||
id: event.id,
|
||||
title: "Fila atualizada",
|
||||
description,
|
||||
when: event.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "ASSIGNEE_CHANGED") {
|
||||
const assigneeName = typeof payload.assigneeName === "string" && payload.assigneeName.trim().length > 0 ? String(payload.assigneeName).trim() : null
|
||||
const title = assigneeName ? "Responsável atribuído" : "Responsável atualizado"
|
||||
const description = assigneeName ? `Agora com ${assigneeName}` : "Chamado sem responsável no momento"
|
||||
return {
|
||||
id: event.id,
|
||||
title,
|
||||
description,
|
||||
when: event.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "CATEGORY_CHANGED") {
|
||||
const categoryName = typeof payload.categoryName === "string" ? payload.categoryName.trim() : ""
|
||||
const subcategoryName = typeof payload.subcategoryName === "string" ? payload.subcategoryName.trim() : ""
|
||||
const hasCategory = categoryName.length > 0
|
||||
const hasSubcategory = subcategoryName.length > 0
|
||||
const description = hasCategory
|
||||
? hasSubcategory
|
||||
? `${categoryName} • ${subcategoryName}`
|
||||
: categoryName
|
||||
: "Categoria removida"
|
||||
return {
|
||||
id: event.id,
|
||||
title: "Categoria atualizada",
|
||||
description,
|
||||
when: event.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "COMMENT_ADDED") {
|
||||
const matchingComment = ticket.comments.find((comment) => comment.createdAt.getTime() === event.createdAt.getTime())
|
||||
if (!matchingComment) {
|
||||
return null
|
||||
}
|
||||
const rawBody = matchingComment.body ?? ""
|
||||
const plainBody = rawBody.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()
|
||||
const summary = plainBody.length > 0 ? (plainBody.length > 140 ? `${plainBody.slice(0, 140)}…` : plainBody) : null
|
||||
const author = matchingComment.author.name || actorName || "Equipe"
|
||||
const description = summary ?? `Comentário registrado por ${author}`
|
||||
return {
|
||||
id: event.id,
|
||||
title: "Novo comentário",
|
||||
description,
|
||||
when: event.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "STATUS_CHANGED") {
|
||||
const toLabel = typeof payload.toLabel === "string" && payload.toLabel.trim().length > 0 ? String(payload.toLabel).trim() : null
|
||||
const toRaw = typeof payload.to === "string" && payload.to.trim().length > 0 ? String(payload.to).trim() : null
|
||||
const normalized = (toLabel ?? toRaw ?? "").toUpperCase()
|
||||
if (!normalized) return null
|
||||
const isFinal = normalized === "RESOLVED" || normalized === "RESOLVIDO" || normalized === "CLOSED" || normalized === "FINALIZADO" || normalized === "FINALIZED"
|
||||
if (!isFinal) return null
|
||||
const description = `Status alterado para ${toLabel ?? toRaw ?? "Resolvido"}`
|
||||
return {
|
||||
id: event.id,
|
||||
title: normalized === "RESOLVED" || normalized === "RESOLVIDO" ? "Chamado resolvido" : "Chamado finalizado",
|
||||
description,
|
||||
when: event.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
.filter((entry): entry is ClientTimelineEntry => entry !== null)
|
||||
.sort((a, b) => b.when.getTime() - a.when.getTime())
|
||||
}, [ticket])
|
||||
|
||||
if (ticketRaw === undefined) {
|
||||
return (
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
|
|
@ -120,7 +223,6 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
|
||||
const createdAt = format(ticket.createdAt, "dd 'de' MMMM 'de' yyyy 'às' HH:mm", { locale: ptBR })
|
||||
const updatedAgo = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!convexUserId || !comment.trim() || !ticket) return
|
||||
|
|
@ -133,9 +235,24 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
authorId: convexUserId as Id<"users">,
|
||||
visibility: "PUBLIC",
|
||||
body: htmlBody,
|
||||
attachments: attachments.map((f) => ({ storageId: f.storageId as Id<"_storage">, name: f.name, size: f.size, type: f.type, })),
|
||||
attachments: attachments.map((f) => ({
|
||||
storageId: f.storageId as Id<"_storage">,
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type,
|
||||
})),
|
||||
})
|
||||
setComment("")
|
||||
attachments.forEach((file) => {
|
||||
if (file.previewUrl?.startsWith("blob:")) {
|
||||
try {
|
||||
URL.revokeObjectURL(file.previewUrl)
|
||||
} catch {
|
||||
// ignore revoke issues
|
||||
}
|
||||
}
|
||||
})
|
||||
setAttachments([])
|
||||
toast.success("Comentário enviado!", { id: toastId })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
|
@ -156,9 +273,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 text-sm">
|
||||
<Badge className="rounded-full bg-neutral-900 px-3 py-1 text-xs font-semibold uppercase text-white">
|
||||
{statusLabel[ticket.status]}
|
||||
</Badge>
|
||||
<TicketStatusBadge status={ticket.status} className="px-3 py-1 text-xs font-semibold uppercase" />
|
||||
{!isCustomer ? (
|
||||
<Badge className={`rounded-full px-3 py-1 text-xs font-semibold uppercase ${priorityTone[ticket.priority]}`}>
|
||||
{priorityLabel[ticket.priority]}
|
||||
|
|
@ -169,7 +284,8 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
</CardHeader>
|
||||
<CardContent className="grid gap-4 border-t border-slate-100 px-5 py-5 text-sm text-neutral-700 sm:grid-cols-2">
|
||||
{isCustomer ? null : <DetailItem label="Fila" value={ticket.queue ?? "Sem fila"} />}
|
||||
<DetailItem label="Categoria" value={ticket.category?.name ?? "Não classificada"} />`n <DetailItem label="Subcategoria" value={ticket.subcategory?.name ?? "—"} />
|
||||
<DetailItem label="Categoria" value={ticket.category?.name ?? "Não classificada"} />
|
||||
<DetailItem label="Subcategoria" value={ticket.subcategory?.name ?? "—"} />
|
||||
<DetailItem label="Solicitante" value={ticket.requester.name} subtitle={ticket.requester.email} />
|
||||
{ticket.assignee ? (
|
||||
<DetailItem label="Responsável" value={ticket.assignee.name} />
|
||||
|
|
@ -187,22 +303,75 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 px-5 pb-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<label htmlFor="comment" className="text-sm font-medium text-neutral-800">
|
||||
Enviar uma mensagem para a equipe
|
||||
</label>
|
||||
<RichTextEditor
|
||||
value={comment}
|
||||
onChange={(html) => setComment(html)}
|
||||
placeholder="Descreva o que aconteceu, envie atualizações ou compartilhe novas informações."
|
||||
className="rounded-2xl border border-slate-200 shadow-sm focus-within:border-neutral-900 focus-within:ring-neutral-900/20"
|
||||
/>
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="comment" className="text-sm font-medium text-neutral-800">
|
||||
Enviar uma mensagem para a equipe
|
||||
</label>
|
||||
<RichTextEditor
|
||||
value={comment}
|
||||
onChange={(html) => setComment(html)}
|
||||
placeholder="Descreva o que aconteceu, envie atualizações ou compartilhe novas informações."
|
||||
className="rounded-2xl border border-slate-200 shadow-sm focus-within:border-neutral-900 focus-within:ring-neutral-900/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Dropzone
|
||||
onUploaded={(files) => setAttachments((prev) => [...prev, ...files])}
|
||||
className="rounded-xl border border-dashed border-slate-300 bg-slate-50 px-3 py-4 text-sm text-neutral-600 shadow-inner"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500">Máximo 10MB • Até 5 arquivos</p>
|
||||
{attachments.length > 0 ? (
|
||||
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
||||
{attachments.map((attachment, index) => {
|
||||
const isImage =
|
||||
(attachment.type ?? "").startsWith("image/") ||
|
||||
/\.(png|jpe?g|gif|webp|svg)$/i.test(attachment.name)
|
||||
return (
|
||||
<div
|
||||
key={`${attachment.storageId}-${index}`}
|
||||
className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5"
|
||||
>
|
||||
{isImage && attachment.previewUrl ? (
|
||||
<div className="block w-full overflow-hidden rounded-md">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={attachment.previewUrl}
|
||||
alt={attachment.name}
|
||||
className="h-24 w-full rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-600">
|
||||
<FileIcon className="size-4" />
|
||||
<span className="line-clamp-2 px-2 text-center">{attachment.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setAttachments((prev) => {
|
||||
const next = [...prev]
|
||||
const removed = next.splice(index, 1)[0]
|
||||
if (removed?.previewUrl?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(removed.previewUrl)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
className="absolute right-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30"
|
||||
aria-label={`Remover ${attachment.name}`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
<div className="mt-1 line-clamp-1 w-full text-ellipsis text-center text-[11px] text-neutral-500">
|
||||
{attachment.name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-xs text-neutral-500">Máximo 5MB • Até 5 arquivos</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" className="rounded-full bg-neutral-900 px-6 text-sm font-semibold text-white hover:bg-neutral-900/90">
|
||||
|
|
@ -248,14 +417,22 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
<span className="text-xs text-neutral-500">{createdAgo}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="rounded-full border-dashed px-3 py-1 text-[11px] uppercase text-neutral-600">
|
||||
{commentItem.visibility === "PUBLIC" ? "Público" : "Interno"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-sm mt-3 max-w-none text-neutral-800"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeEditorHtml(commentItem.body ?? "") }}
|
||||
/>
|
||||
{commentItem.attachments && commentItem.attachments.length > 0 ? (
|
||||
<div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3">
|
||||
{commentItem.attachments.map((attachment) => (
|
||||
<PortalCommentAttachmentCard
|
||||
key={attachment.id}
|
||||
attachment={attachment}
|
||||
getFileUrl={getFileUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
@ -269,22 +446,21 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
<CardTitle className="text-lg font-semibold text-neutral-900">Linha do tempo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 px-5 pb-6 text-sm text-neutral-700">
|
||||
{ticket.timeline.length === 0 ? (
|
||||
{clientTimeline.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">Nenhum evento registrado ainda.</p>
|
||||
) : (
|
||||
ticket.timeline
|
||||
.slice()
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
.map((event) => {
|
||||
const label = timelineLabels[event.type] ?? event.type
|
||||
const when = formatDistanceToNow(event.createdAt, { addSuffix: true, locale: ptBR })
|
||||
return (
|
||||
<div key={event.id} className="flex flex-col gap-1 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
|
||||
<span className="text-sm font-semibold text-neutral-900">{label}</span>
|
||||
<span className="text-xs text-neutral-500">{when}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
clientTimeline.map((event) => {
|
||||
const when = formatDistanceToNow(event.when, { addSuffix: true, locale: ptBR })
|
||||
return (
|
||||
<div key={event.id} className="flex flex-col gap-1 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
|
||||
<span className="text-sm font-semibold text-neutral-900">{event.title}</span>
|
||||
{event.description ? (
|
||||
<span className="text-xs text-neutral-600">{event.description}</span>
|
||||
) : null}
|
||||
<span className="text-xs text-neutral-500">{when}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -308,6 +484,121 @@ function DetailItem({ label, value, subtitle }: DetailItemProps) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CommentAttachment = TicketWithDetails["comments"][number]["attachments"][number]
|
||||
type GetFileUrlAction = (args: { storageId: Id<"_storage"> }) => Promise<string | null>
|
||||
|
||||
function PortalCommentAttachmentCard({ attachment, getFileUrl }: { attachment: CommentAttachment; getFileUrl: GetFileUrlAction }) {
|
||||
const [url, setUrl] = useState<string | null>(attachment.url ?? null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errored, setErrored] = useState(false)
|
||||
|
||||
const isImageType = useMemo(() => {
|
||||
const name = attachment.name ?? ""
|
||||
const type = attachment.type ?? ""
|
||||
return type.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg)$/i.test(name)
|
||||
}, [attachment.name, attachment.type])
|
||||
|
||||
const ensureUrl = useCallback(async () => {
|
||||
if (url) return url
|
||||
try {
|
||||
setLoading(true)
|
||||
const fresh = await getFileUrl({ storageId: attachment.id as Id<"_storage"> })
|
||||
if (fresh) {
|
||||
setUrl(fresh)
|
||||
setErrored(false)
|
||||
return fresh
|
||||
}
|
||||
setErrored(true)
|
||||
} catch (error) {
|
||||
console.error("Falha ao obter URL do anexo", error)
|
||||
setErrored(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
return null
|
||||
}, [attachment.id, getFileUrl, url])
|
||||
|
||||
useEffect(() => {
|
||||
if (attachment.url) {
|
||||
setUrl(attachment.url)
|
||||
setErrored(false)
|
||||
return
|
||||
}
|
||||
if (isImageType) {
|
||||
void ensureUrl()
|
||||
}
|
||||
}, [attachment.url, ensureUrl, isImageType])
|
||||
|
||||
const handlePreview = useCallback(async () => {
|
||||
const target = await ensureUrl()
|
||||
if (target) {
|
||||
window.open(target, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
}, [ensureUrl])
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
const target = await ensureUrl()
|
||||
if (!target) return
|
||||
try {
|
||||
const link = document.createElement("a")
|
||||
link.href = target
|
||||
link.download = attachment.name ?? "anexo"
|
||||
link.rel = "noopener noreferrer"
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
} catch (error) {
|
||||
console.error("Falha ao iniciar download do anexo", error)
|
||||
window.open(target, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
}, [attachment.name, ensureUrl])
|
||||
|
||||
const resolvedUrl = url
|
||||
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
|
||||
{isImageType && resolvedUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreview}
|
||||
className="relative block w-full overflow-hidden rounded-md"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/70">
|
||||
<Spinner className="size-5 text-neutral-600" />
|
||||
</div>
|
||||
) : null}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={resolvedUrl} alt={attachment.name ?? "Anexo"} className="h-24 w-full rounded-md object-cover" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-700 transition hover:bg-slate-100"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <Spinner className="size-5 text-neutral-600" /> : <FileIcon className="size-5 text-neutral-600" />}
|
||||
<span className="font-medium">
|
||||
{errored ? "Gerar link novamente" : "Baixar"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="absolute left-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30"
|
||||
aria-label={`Baixar ${attachment.name ?? "anexo"}`}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
<div className="mt-1 line-clamp-2 w-full text-ellipsis text-center text-[11px] text-neutral-500">
|
||||
{attachment.name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue