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:
parent
be27dcfd15
commit
a51783ce29
11 changed files with 338 additions and 537 deletions
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
3
web/pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
87
web/scripts/reassign-legacy-assignees.mjs
Normal file
87
web/scripts/reassign-legacy-assignees.mjs
Normal 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
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue