feat: harden ticket creation ux and seeding

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
rever-tecnologia 2025-10-06 14:40:26 -03:00
parent be27dcfd15
commit a51783ce29
11 changed files with 338 additions and 537 deletions

View file

@ -43,9 +43,10 @@ export const seedDemo = mutation({
if (found) return found._id;
return await ctx.db.insert("users", { tenantId, name, email, role, avatarUrl: `https://avatar.vercel.sh/${name.split(" ")[0]}` });
}
const anaId = await ensureUser("Ana Souza", "ana.souza@example.com");
const brunoId = await ensureUser("Bruno Lima", "bruno.lima@example.com");
const reverId = await ensureUser("Rever", "renan.pac@paulicon.com.br");
const agenteDemoId = await ensureUser("Agente Demo", "agente.demo@sistema.dev");
const eduardaId = await ensureUser("Eduarda Rocha", "eduarda.rocha@example.com", "CUSTOMER");
const clienteDemoId = await ensureUser("Cliente Demo", "cliente.demo@sistema.dev", "CUSTOMER");
// Seed a couple of tickets
const now = Date.now();
@ -68,7 +69,7 @@ export const seedDemo = mutation({
channel: "EMAIL",
queueId: queue1,
requesterId: eduardaId,
assigneeId: anaId,
assigneeId: reverId,
createdAt: now - 1000 * 60 * 60 * 5,
updatedAt: now - 1000 * 60 * 10,
tags: ["portal", "cliente"],
@ -84,8 +85,8 @@ export const seedDemo = mutation({
priority: "HIGH",
channel: "WHATSAPP",
queueId: queue2,
requesterId: eduardaId,
assigneeId: brunoId,
requesterId: clienteDemoId,
assigneeId: agenteDemoId,
createdAt: now - 1000 * 60 * 60 * 8,
updatedAt: now - 1000 * 60 * 30,
tags: ["Integração", "erp"],

View file

@ -48,6 +48,7 @@
"lucide-react": "^0.544.0",
"next": "15.5.4",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hook-form": "^7.64.0",

3
web/pnpm-lock.yaml generated
View file

@ -113,6 +113,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
postcss:
specifier: ^8.5.6
version: 8.5.6
react:
specifier: 19.2.0
version: 19.2.0

View file

@ -0,0 +1,87 @@
import { ConvexHttpClient } from "convex/browser"
const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL
const TENANT_ID = process.env.SEED_TENANT_ID ?? "tenant-atlas"
if (!CONVEX_URL) {
console.error("NEXT_PUBLIC_CONVEX_URL não definido. Configure o endpoint do Convex e execute novamente.")
process.exit(1)
}
const TARGET_NAMES = new Set(["Ana Souza", "Bruno Lima"])
const REPLACEMENT = {
name: "Rever",
email: "renan.pac@paulicon.com.br",
}
async function main() {
const client = new ConvexHttpClient(CONVEX_URL)
const admin = await client.mutation("users:ensureUser", {
tenantId: TENANT_ID,
email: "admin@sistema.dev",
name: "Administrador",
role: "ADMIN",
})
if (!admin?._id) {
throw new Error("Não foi possível garantir o usuário administrador")
}
const replacementUser = await client.mutation("users:ensureUser", {
tenantId: TENANT_ID,
email: REPLACEMENT.email,
name: REPLACEMENT.name,
role: "AGENT",
})
if (!replacementUser?._id) {
throw new Error("Não foi possível garantir o usuário Rever")
}
const agents = await client.query("users:listAgents", { tenantId: TENANT_ID })
const targets = agents.filter((agent) => TARGET_NAMES.has(agent.name))
if (targets.length === 0) {
console.log("Nenhum responsável legado encontrado. Nada a atualizar.")
}
const targetIds = new Set(targets.map((agent) => agent._id))
const tickets = await client.query("tickets:list", {
tenantId: TENANT_ID,
viewerId: admin._id,
})
let reassignedCount = 0
for (const ticket of tickets) {
if (ticket.assignee && targetIds.has(ticket.assignee.id)) {
await client.mutation("tickets:changeAssignee", {
ticketId: ticket.id,
assigneeId: replacementUser._id,
actorId: admin._id,
})
reassignedCount += 1
console.log(`Ticket ${ticket.reference} reatribuído para ${replacementUser.name}`)
}
}
for (const agent of targets) {
try {
await client.mutation("users:deleteUser", {
userId: agent._id,
actorId: admin._id,
})
console.log(`Usuário removido: ${agent.name}`)
} catch (error) {
console.error(`Falha ao remover ${agent.name}:`, error)
}
}
console.log(`Total de tickets reatribuídos: ${reassignedCount}`)
}
main().catch((error) => {
console.error("Erro ao reatribuir responsáveis legacy:", error)
process.exitCode = 1
})

View file

@ -4,13 +4,43 @@ import { hashPassword } from "better-auth/crypto"
const { PrismaClient } = pkg
const prisma = new PrismaClient()
const email = process.env.SEED_USER_EMAIL ?? "admin@sistema.dev"
const password = process.env.SEED_USER_PASSWORD ?? "admin123"
const name = process.env.SEED_USER_NAME ?? "Administrador"
const role = process.env.SEED_USER_ROLE ?? "admin"
const tenantId = process.env.SEED_USER_TENANT ?? "tenant-atlas"
async function main() {
const singleUserFromEnv = process.env.SEED_USER_EMAIL
? [{
email: process.env.SEED_USER_EMAIL,
password: process.env.SEED_USER_PASSWORD ?? "admin123",
name: process.env.SEED_USER_NAME ?? "Administrador",
role: process.env.SEED_USER_ROLE ?? "admin",
tenantId,
}]
: null
const defaultUsers = singleUserFromEnv ?? [
{
email: "admin@sistema.dev",
password: "admin123",
name: "Administrador",
role: "admin",
tenantId,
},
{
email: "agente.demo@sistema.dev",
password: "agent123",
name: "Agente Demo",
role: "agent",
tenantId,
},
{
email: "cliente.demo@sistema.dev",
password: "cliente123",
name: "Cliente Demo",
role: "customer",
tenantId,
},
]
async function upsertAuthUser({ email, password, name, role, tenantId: userTenant }: (typeof defaultUsers)[number]) {
const hashedPassword = await hashPassword(password)
const user = await prisma.authUser.upsert({
@ -18,13 +48,13 @@ async function main() {
update: {
name,
role,
tenantId,
tenantId: userTenant,
},
create: {
email,
name,
role,
tenantId,
tenantId: userTenant,
accounts: {
create: {
providerId: "credential",
@ -79,7 +109,13 @@ async function main() {
console.log(` Role: ${user.role}`)
console.log(` Tenant: ${user.tenantId ?? "(nenhum)"}`)
console.log(` Provider: ${account?.providerId ?? "-"}`)
console.log(`Senha provisoria: ${password}`)
console.log(` Senha provisoria: ${password}`)
}
async function main() {
for (const user of defaultUsers) {
await upsertAuthUser(user)
}
}
main()

View file

@ -2,6 +2,7 @@ import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { TicketDetailView } from "@/components/tickets/ticket-detail-view"
import { TicketDetailStatic } from "@/components/tickets/ticket-detail-static"
import { NewTicketDialog } from "@/components/tickets/new-ticket-dialog"
import { getTicketById } from "@/lib/mocks/tickets"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
@ -21,7 +22,7 @@ export default async function TicketDetailPage({ params }: TicketDetailPageProps
title={`Ticket #${id}`}
lead={"Detalhes do ticket"}
secondaryAction={<SiteHeader.SecondaryButton>Compartilhar</SiteHeader.SecondaryButton>}
primaryAction={<SiteHeader.PrimaryButton>Adicionar comentário</SiteHeader.PrimaryButton>}
primaryAction={<NewTicketDialog />}
/>
}
>

View file

@ -14,7 +14,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { RichTextEditor } from "@/components/ui/rich-text-editor"
import { RichTextEditor, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { Spinner } from "@/components/ui/spinner"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
@ -59,6 +59,7 @@ export default function NewTicketPage() {
const [subcategoryId, setSubcategoryId] = useState<string | null>(null)
const [categoryError, setCategoryError] = useState<string | null>(null)
const [subcategoryError, setSubcategoryError] = useState<string | null>(null)
const [descriptionError, setDescriptionError] = useState<string | null>(null)
const [assigneeInitialized, setAssigneeInitialized] = useState(false)
const queueOptions = useMemo(() => queues.map((q) => q.name), [queues])
@ -91,6 +92,14 @@ export default function NewTicketPage() {
return
}
const sanitizedDescription = sanitizeEditorHtml(description)
const plainDescription = sanitizedDescription.replace(/<[^>]*>/g, "").trim()
if (plainDescription.length === 0) {
setDescriptionError("Descreva o contexto do chamado.")
return
}
setDescriptionError(null)
setLoading(true)
toast.loading("Criando ticket...", { id: "create-ticket" })
try {
@ -110,13 +119,12 @@ export default function NewTicketPage() {
categoryId: categoryId as Id<"ticketCategories">,
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
})
const plainDescription = description.replace(/<[^>]*>/g, "").trim()
if (plainDescription.length > 0) {
await addComment({
ticketId: id as Id<"tickets">,
authorId: convexUserId as Id<"users">,
visibility: "PUBLIC",
body: description,
body: sanitizedDescription,
attachments: [],
})
}
@ -143,8 +151,8 @@ export default function NewTicketPage() {
<CardContent>
<form onSubmit={submit} className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700" htmlFor="subject">
Assunto
<label className="flex items-center gap-1 text-sm font-medium text-neutral-700" htmlFor="subject">
Assunto <span className="text-red-500">*</span>
</label>
<Input
id="subject"
@ -171,8 +179,23 @@ export default function NewTicketPage() {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Descrição</label>
<RichTextEditor value={description} onChange={setDescription} placeholder="Detalhe o problema, passos para reproduzir, links, etc." />
<label className="flex items-center gap-1 text-sm font-medium text-neutral-700">
Descrição <span className="text-red-500">*</span>
</label>
<RichTextEditor
value={description}
onChange={(html) => {
setDescription(html)
if (descriptionError) {
const plain = html.replace(/<[^>]*>/g, "").trim()
if (plain.length > 0) {
setDescriptionError(null)
}
}
}}
placeholder="Detalhe o problema, passos para reproduzir, links, etc."
/>
{descriptionError ? <p className="text-xs font-medium text-red-500">{descriptionError}</p> : null}
</div>
<div className="space-y-2">
<CategorySelectFields
@ -187,6 +210,8 @@ export default function NewTicketPage() {
setSubcategoryId(value)
setSubcategoryError(null)
}}
categoryLabel="Categoria primária *"
subcategoryLabel="Categoria secundária *"
/>
{categoryError || subcategoryError ? (
<div className="text-xs font-medium text-red-500">

View file

@ -52,8 +52,8 @@ export function PortalTicketForm() {
const [isSubmitting, setIsSubmitting] = useState(false)
const isFormValid = useMemo(() => {
return Boolean(subject.trim() && categoryId && subcategoryId)
}, [subject, categoryId, subcategoryId])
return Boolean(subject.trim() && description.trim() && categoryId && subcategoryId)
}, [subject, description, categoryId, subcategoryId])
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
@ -61,6 +61,7 @@ export function PortalTicketForm() {
const trimmedSubject = subject.trim()
const trimmedSummary = summary.trim()
const trimmedDescription = description.trim()
setIsSubmitting(true)
toast.loading("Abrindo chamado...", { id: "portal-new-ticket" })
@ -78,8 +79,8 @@ export function PortalTicketForm() {
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
})
if (description.trim().length > 0) {
const htmlBody = sanitizeEditorHtml(toHtml(description.trim()))
if (trimmedDescription.length > 0) {
const htmlBody = sanitizeEditorHtml(toHtml(trimmedDescription))
await addComment({
ticketId: id as Id<"tickets">,
authorId: convexUserId as Id<"users">,
@ -108,8 +109,8 @@ export function PortalTicketForm() {
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-3">
<div className="space-y-1">
<label htmlFor="subject" className="text-sm font-medium text-neutral-800">
Assunto
<label htmlFor="subject" className="flex items-center gap-1 text-sm font-medium text-neutral-800">
Assunto <span className="text-red-500">*</span>
</label>
<Input
id="subject"
@ -131,14 +132,15 @@ export function PortalTicketForm() {
/>
</div>
<div className="space-y-1">
<label htmlFor="description" className="text-sm font-medium text-neutral-800">
Detalhes
<label htmlFor="description" className="flex items-center gap-1 text-sm font-medium text-neutral-800">
Detalhes <span className="text-red-500">*</span>
</label>
<Textarea
id="description"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Compartilhe passos para reproduzir, mensagens de erro ou informações adicionais."
required
className="min-h-[140px] resize-y rounded-xl border border-slate-200 px-4 py-3 text-sm text-neutral-800 shadow-sm focus-visible:border-neutral-900 focus-visible:ring-neutral-900/20"
/>
</div>
@ -168,8 +170,8 @@ export function PortalTicketForm() {
onCategoryChange={setCategoryId}
onSubcategoryChange={setSubcategoryId}
layout="stacked"
categoryLabel="Categoria"
subcategoryLabel="Subcategoria"
categoryLabel="Categoria *"
subcategoryLabel="Subcategoria *"
secondaryEmptyLabel="Selecione uma categoria"
/>
</div>

View file

@ -19,7 +19,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Spinner } from "@/components/ui/spinner"
import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor } from "@/components/ui/rich-text-editor"
import { RichTextEditor, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import {
PriorityIcon,
priorityStyles,
@ -27,9 +27,9 @@ import {
import { CategorySelectFields } from "@/components/tickets/category-select"
const schema = z.object({
subject: z.string().optional(),
subject: z.string().default(""),
summary: z.string().optional(),
description: z.string().optional(),
description: z.string().default(""),
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
queueName: z.string().nullable().optional(),
@ -124,11 +124,20 @@ export function NewTicketDialog() {
async function submit(values: z.infer<typeof schema>) {
if (!convexUserId) return
const subjectTrimmed = (values.subject ?? "").trim()
if (subjectTrimmed.length < 3) {
form.setError("subject", { type: "min", message: "Informe um assunto" })
form.setError("subject", { type: "min", message: "Informe um assunto com pelo menos 3 caracteres." })
return
}
const sanitizedDescription = sanitizeEditorHtml(values.description ?? "")
const plainDescription = sanitizedDescription.replace(/<[^>]*>/g, "").trim()
if (plainDescription.length === 0) {
form.setError("description", { type: "custom", message: "Descreva o contexto do chamado." })
return
}
setLoading(true)
toast.loading("Criando ticket…", { id: "new-ticket" })
try {
@ -138,7 +147,7 @@ export function NewTicketDialog() {
actorId: convexUserId as Id<"users">,
tenantId: DEFAULT_TENANT_ID,
subject: subjectTrimmed,
summary: values.summary,
summary: values.summary?.trim() || undefined,
priority: values.priority,
channel: values.channel,
queueId: sel?.id as Id<"queues"> | undefined,
@ -147,8 +156,8 @@ export function NewTicketDialog() {
categoryId: values.categoryId as Id<"ticketCategories">,
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
})
const hasDescription = (values.description ?? "").replace(/<[^>]*>/g, "").trim().length > 0
const bodyHtml = hasDescription ? (values.description as string) : (values.summary || "")
const summaryFallback = values.summary?.trim() ?? ""
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
if (attachments.length > 0 || bodyHtml.trim().length > 0) {
const typedAttachments = attachments.map((a) => ({
storageId: a.storageId as unknown as Id<"_storage">,
@ -160,7 +169,18 @@ export function NewTicketDialog() {
}
toast.success("Ticket criado!", { id: "new-ticket" })
setOpen(false)
form.reset()
form.reset({
subject: "",
summary: "",
description: "",
priority: "MEDIUM",
channel: "MANUAL",
queueName: null,
assigneeId: convexUserId ?? null,
categoryId: "",
subcategoryId: "",
})
form.clearErrors()
setAssigneeInitialized(false)
setAttachments([])
// Navegar para o ticket recém-criado
@ -213,7 +233,9 @@ export function NewTicketDialog() {
<FieldGroup className="lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<div className="space-y-4">
<Field>
<FieldLabel htmlFor="subject">Assunto</FieldLabel>
<FieldLabel htmlFor="subject" className="flex items-center gap-1">
Assunto <span className="text-destructive">*</span>
</FieldLabel>
<Input id="subject" {...form.register("subject")} placeholder="Ex.: Erro 500 no portal" />
<FieldError errors={form.formState.errors.subject ? [{ message: form.formState.errors.subject.message }] : []} />
</Field>
@ -227,12 +249,27 @@ export function NewTicketDialog() {
/>
</Field>
<Field>
<FieldLabel>Descrição</FieldLabel>
<FieldLabel className="flex items-center gap-1">
Descrição <span className="text-destructive">*</span>
</FieldLabel>
<RichTextEditor
value={form.watch("description") || ""}
onChange={(html) => form.setValue("description", html)}
onChange={(html) =>
form.setValue("description", html, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: form.formState.isSubmitted,
})
}
placeholder="Detalhe o problema, passos para reproduzir, links, etc."
/>
<FieldError
errors={
form.formState.errors.description
? [{ message: form.formState.errors.description.message }]
: []
}
/>
</Field>
<Field>
<FieldLabel>Anexos</FieldLabel>
@ -251,8 +288,8 @@ export function NewTicketDialog() {
subcategoryId={subcategoryIdValue || null}
onCategoryChange={handleCategoryChange}
onSubcategoryChange={handleSubcategoryChange}
categoryLabel="Categoria primária"
subcategoryLabel="Categoria secundária"
categoryLabel="Categoria primária *"
subcategoryLabel="Categoria secundária *"
layout="stacked"
/>
{form.formState.errors.categoryId?.message || form.formState.errors.subcategoryId?.message ? (

View file

@ -17,20 +17,13 @@ const tenantId = "tenant-atlas"
type UserRecord = z.infer<typeof ticketSchema>["requester"]
const users: Record<string, UserRecord> = {
ana: {
id: "user-ana",
name: "Ana Souza",
email: "ana.souza@example.com",
avatarUrl: "https://avatar.vercel.sh/ana",
rever: {
id: "user-rever",
name: "Rever",
email: "renan.pac@paulicon.com.br",
avatarUrl: "https://avatar.vercel.sh/rever",
teams: ["Chamados"],
},
bruno: {
id: "user-bruno",
name: "Bruno Lima",
email: "bruno.lima@example.com",
avatarUrl: "https://avatar.vercel.sh/bruno",
teams: ["Chamados"],
},
},
carla: {
id: "user-carla",
name: "Carla Menezes",
@ -72,7 +65,7 @@ const baseTickets = [
channel: ticketChannelSchema.enum.EMAIL,
queue: "Chamados",
requester: users.eduarda,
assignee: users.ana,
assignee: users.rever,
slaPolicy: {
id: "sla-critical",
name: "SLA Crítico",
@ -85,7 +78,7 @@ const baseTickets = [
updatedAt: subMinutes(new Date(), 10),
createdAt: subHours(new Date(), 5),
tags: ["portal", "cliente"],
lastTimelineEntry: "Prioridade atualizada para URGENT por Bruno",
lastTimelineEntry: "Prioridade atualizada para URGENT por Rever",
metrics: {
timeWaitingMinutes: 12,
timeOpenedMinutes: 300,
@ -183,7 +176,7 @@ const baseTickets = [
channel: ticketChannelSchema.enum.EMAIL,
queue: "Chamados",
requester: users.eduarda,
assignee: users.bruno,
assignee: users.rever,
slaPolicy: {
id: "sla-standard",
name: "SLA Padrão",
@ -196,7 +189,7 @@ const baseTickets = [
updatedAt: subMinutes(new Date(), 5),
createdAt: subHours(new Date(), 20),
tags: ["financeiro"],
lastTimelineEntry: "Ticket resolvido, aguardando confirmação do cliente",
lastTimelineEntry: "Ticket resolvido, aguardando confirmação do cliente",
metrics: {
timeWaitingMinutes: 30,
timeOpenedMinutes: 1100,
@ -207,26 +200,26 @@ const baseTickets = [
export const tickets = baseTickets as Array<z.infer<typeof ticketSchema>>
const commentsByTicket: Record<string, z.infer<typeof ticketWithDetailsSchema>["comments"]> = {
"ticket-1001": [
{
id: "comment-1",
author: users.ana,
visibility: commentVisibilitySchema.enum.INTERNAL,
body: "Logs coletados e enviados para o time de infraestrutura.",
attachments: [],
createdAt: subMinutes(new Date(), 40),
updatedAt: subMinutes(new Date(), 40),
},
{
id: "comment-2",
author: users.bruno,
visibility: commentVisibilitySchema.enum.PUBLIC,
body: "Estamos investigando o incidente, retorno em 30 minutos.",
attachments: [],
createdAt: subMinutes(new Date(), 25),
updatedAt: subMinutes(new Date(), 25),
},
],
"ticket-1001": [
{
id: "comment-1",
author: users.rever,
visibility: commentVisibilitySchema.enum.INTERNAL,
body: "Logs coletados e enviados para o time de infraestrutura.",
attachments: [],
createdAt: subMinutes(new Date(), 40),
updatedAt: subMinutes(new Date(), 40),
},
{
id: "comment-2",
author: users.rever,
visibility: commentVisibilitySchema.enum.PUBLIC,
body: "Estamos investigando o incidente, retorno em 30 minutos.",
attachments: [],
createdAt: subMinutes(new Date(), 25),
updatedAt: subMinutes(new Date(), 25),
},
],
"ticket-1002": [
{
id: "comment-3",
@ -251,13 +244,13 @@ const timelineByTicket: Record<string, z.infer<typeof ticketWithDetailsSchema>["
{
id: "timeline-2",
type: "ASSIGNEE_CHANGED",
payload: { assignee: users.ana.name },
payload: { assignee: users.rever.name },
createdAt: subHours(new Date(), 4),
},
{
id: "timeline-3",
type: "COMMENT_ADDED",
payload: { author: users.ana.name },
payload: { author: users.rever.name },
createdAt: subHours(new Date(), 1),
},
],