sistema-de-chamados/src/components/tickets/new-ticket-dialog.tsx
rever-tecnologia 1e674d5006
All checks were successful
CI/CD Web + Desktop / Detect changes (push) Successful in 5s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 4m0s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been skipped
Quality Checks / Lint, Test and Build (push) Successful in 4m17s
style(tickets): aumenta largura do botao Criar
2025-12-17 10:25:42 -03:00

1530 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { z } from "zod"
import { useEffect, useMemo, useRef, useState } from "react"
import { format, parseISO } from "date-fns"
import { ptBR } from "date-fns/locale"
import type { Doc, Id } from "@/convex/_generated/dataModel"
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
import { useMutation, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { FieldSet, FieldGroup, Field, FieldLabel, FieldError } from "@/components/ui/field"
import { useForm } from "react-hook-form"
import { zodResolver } from "@/lib/zod-resolver"
import { toast } from "sonner"
import { Spinner } from "@/components/ui/spinner"
import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { PriorityIcon } from "@/components/tickets/priority-select"
import { CategorySelectFields } from "@/components/tickets/category-select"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { useDefaultQueues } from "@/hooks/use-default-queues"
import { useLocalTimeZone } from "@/hooks/use-local-time-zone"
import { cn } from "@/lib/utils"
import { priorityStyles } from "@/lib/ticket-priority-style"
import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers"
import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types"
import { Calendar as CalendarIcon, ListChecks, Plus, Trash2 } from "lucide-react"
import { TimePicker } from "@/components/ui/time-picker"
import { VISIT_KEYWORDS } from "@/lib/ticket-matchers"
import { Checkbox } from "@/components/ui/checkbox"
type TriggerVariant = "button" | "card"
type CustomerOption = {
id: string
name: string
email: string
role: string
companyId: string | null
companyName: string | null
companyIsAvulso: boolean
avatarUrl: string | null
}
type ChecklistDraftItem = {
id: string
text: string
required: boolean
}
type ChecklistTemplateOption = {
id: Id<"ticketChecklistTemplates">
name: string
company: { id: Id<"companies">; name: string } | null
items: Array<{ id: string; text: string; required: boolean }>
}
function getInitials(name: string | null | undefined, fallback: string): string {
const normalizedName = (name ?? "").trim()
if (normalizedName.length > 0) {
const parts = normalizedName.split(/\s+/).slice(0, 2)
const initials = parts.map((part) => part.charAt(0).toUpperCase()).join("")
if (initials.length > 0) {
return initials
}
}
const normalizedFallback = (fallback ?? "").trim()
return normalizedFallback.length > 0 ? normalizedFallback.charAt(0).toUpperCase() : "?"
}
function toHtml(text: string) {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
return `<p>${escaped.replace(/\n/g, "<br />")}</p>`
}
type RequesterPreviewProps = {
customer: CustomerOption | null
company: { id: string; name: string; isAvulso?: boolean } | null
}
function RequesterPreview({ customer, company }: RequesterPreviewProps) {
if (!customer) {
return (
<div className="mb-3 rounded-xl border border-dashed border-border/80 bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
Selecione um solicitante para visualizar os detalhes aqui.
</div>
)
}
const initials = getInitials(customer.name, customer.email)
const companyLabel = customer.companyName ?? company?.name ?? "Sem empresa"
return (
<div className="mb-3 flex flex-col gap-2 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 shadow-sm">
<div className="flex items-center gap-3">
<Avatar className="size-9 border border-border/60 bg-white text-sm font-semibold uppercase">
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="min-w-0 space-y-0.5">
<p className="truncate font-semibold text-foreground">{customer.name || customer.email}</p>
<p className="truncate text-xs text-muted-foreground">{customer.email}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="rounded-full border-slate-200 px-2.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{companyLabel}
</Badge>
</div>
</div>
)
}
const NO_COMPANY_VALUE = "__no_company__"
const schema = z.object({
subject: z.string().default(""),
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(),
visitDate: z.string().nullable().optional(),
visitTime: z.string().nullable().optional(),
assigneeId: z.string().nullable().optional(),
companyId: z.string().optional(),
requesterId: z.string().min(1, "Selecione um solicitante"),
categoryId: z.string().min(1, "Selecione uma categoria"),
subcategoryId: z.string().min(1, "Selecione uma categoria secundária"),
})
export function NewTicketDialog({
triggerClassName,
triggerVariant = "button",
}: {
triggerClassName?: string
triggerVariant?: TriggerVariant
} = {}) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const calendarTimeZone = useLocalTimeZone()
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
subject: "",
description: "",
priority: "MEDIUM",
channel: "MANUAL",
queueName: null,
visitDate: null,
visitTime: null,
assigneeId: null,
companyId: NO_COMPANY_VALUE,
requesterId: "",
categoryId: "",
subcategoryId: "",
},
mode: "onTouched",
})
const { convexUserId, isStaff, role, session, machineContext } = useAuth()
const sessionUser = session?.user ?? null
const queuesEnabled = Boolean(isStaff && convexUserId)
useDefaultQueues(DEFAULT_TENANT_ID)
const queuesRemote = useQuery(
api.queues.summary,
queuesEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
)
const queues = useMemo(
() => (Array.isArray(queuesRemote) ? (queuesRemote as TicketQueueSummary[]) : []),
[queuesRemote]
)
const create = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment)
const staffRaw = useQuery(api.users.listAgents, { tenantId: DEFAULT_TENANT_ID }) as Doc<"users">[] | undefined
const staff = useMemo(
() => (staffRaw ?? []).sort((a, b) => a.name.localeCompare(b.name, "pt-BR")),
[staffRaw]
)
const directoryQueryEnabled = queuesEnabled && Boolean(convexUserId)
const companiesRemote = useQuery(
api.companies.list,
directoryQueryEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
)
const companies = useMemo(
() =>
(Array.isArray(companiesRemote) ? companiesRemote : []).map((company) => ({
id: String(company.id),
name: company.name,
slug: company.slug ?? null,
})),
[companiesRemote]
)
const ensureTicketFormDefaultsMutation = useMutation(api.tickets.ensureTicketFormDefaults)
const hasEnsuredFormsRef = useRef(false)
useEffect(() => {
if (!convexUserId || hasEnsuredFormsRef.current) return
hasEnsuredFormsRef.current = true
ensureTicketFormDefaultsMutation({
tenantId: DEFAULT_TENANT_ID,
actorId: convexUserId as Id<"users">,
}).catch((error) => {
console.error("Falha ao preparar formulários personalizados", error)
hasEnsuredFormsRef.current = false
})
}, [convexUserId, ensureTicketFormDefaultsMutation])
const companyValue = form.watch("companyId") ?? NO_COMPANY_VALUE
const formsRemote = useQuery(
api.tickets.listTicketForms,
convexUserId
? {
tenantId: DEFAULT_TENANT_ID,
viewerId: convexUserId as Id<"users">,
companyId: companyValue !== NO_COMPANY_VALUE ? (companyValue as Id<"companies">) : undefined,
}
: "skip"
) as TicketFormDefinition[] | undefined
const checklistTemplates = useQuery(
api.checklistTemplates.listActive,
convexUserId
? {
tenantId: DEFAULT_TENANT_ID,
viewerId: convexUserId as Id<"users">,
companyId: companyValue !== NO_COMPANY_VALUE ? (companyValue as Id<"companies">) : undefined,
}
: "skip"
) as ChecklistTemplateOption[] | undefined
const forms = useMemo<TicketFormDefinition[]>(() => {
const fallback: TicketFormDefinition = {
key: "default",
label: "Chamado",
description: "Formulário básico para abertura de chamados gerais.",
fields: [],
}
if (!formsRemote || formsRemote.length === 0) {
return [fallback]
}
const hasDefault = formsRemote.some((form) => form.key === fallback.key)
if (hasDefault) {
return formsRemote
}
return [fallback, ...formsRemote]
}, [formsRemote])
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
const [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
const [visitDatePickerOpen, setVisitDatePickerOpen] = useState(false)
const [manualChecklist, setManualChecklist] = useState<ChecklistDraftItem[]>([])
const [appliedChecklistTemplateIds, setAppliedChecklistTemplateIds] = useState<string[]>([])
const [checklistTemplateToApply, setChecklistTemplateToApply] = useState<string>("")
const [checklistItemText, setChecklistItemText] = useState("")
const [checklistItemRequired, setChecklistItemRequired] = useState(true)
const appliedChecklistTemplates = useMemo(() => {
const selected = new Set(appliedChecklistTemplateIds.map(String))
return (checklistTemplates ?? []).filter((tpl) => selected.has(String(tpl.id)))
}, [appliedChecklistTemplateIds, checklistTemplates])
const checklistTemplatePreviewItems = useMemo(
() =>
appliedChecklistTemplates.flatMap((tpl) =>
(tpl.items ?? []).map((item) => ({
key: `${String(tpl.id)}:${item.id}`,
text: item.text,
required: item.required,
templateName: tpl.name,
}))
),
[appliedChecklistTemplates]
)
const selectedForm = useMemo(() => forms.find((formDef) => formDef.key === selectedFormKey) ?? forms[0], [forms, selectedFormKey])
const handleFormSelection = (key: string) => {
setSelectedFormKey(key)
setCustomFieldValues({})
}
const handleCustomFieldChange = (field: TicketFormFieldDefinition, value: unknown) => {
setCustomFieldValues((prev) => ({
...prev,
[field.id]: value,
}))
}
const customersRemote = useQuery(
api.users.listCustomers,
directoryQueryEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
)
const rawCustomers = useMemo(
() => (Array.isArray(customersRemote) ? (customersRemote as CustomerOption[]) : []),
[customersRemote]
)
const viewerCustomer = useMemo<CustomerOption | null>(() => {
if (!convexUserId || !sessionUser) return null
return {
id: convexUserId,
name: sessionUser.name ?? sessionUser.email,
email: sessionUser.email,
role: sessionUser.role ?? "customer",
companyId: machineContext?.companyId ?? null,
companyName: null,
companyIsAvulso: false,
avatarUrl: sessionUser.avatarUrl,
}
}, [convexUserId, sessionUser, machineContext?.companyId])
const customers = useMemo(() => {
if (!viewerCustomer) return rawCustomers
const exists = rawCustomers.some((customer) => customer.id === viewerCustomer.id)
return exists ? rawCustomers : [...rawCustomers, viewerCustomer]
}, [rawCustomers, viewerCustomer])
const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
const [customersInitialized, setCustomersInitialized] = useState(false)
const attachmentsTotalBytes = useMemo(
() => attachments.reduce((acc, item) => acc + (item.size ?? 0), 0),
[attachments]
)
const priorityValue = form.watch("priority") as TicketPriority
const queueValue = form.watch("queueName") ?? "NONE"
const visitDateValue = form.watch("visitDate") ?? null
const visitTimeValue = form.watch("visitTime") ?? null
const assigneeValue = form.watch("assigneeId") ?? null
const assigneeSelectValue = assigneeValue ?? "NONE"
const requesterValue = form.watch("requesterId") ?? ""
const categoryIdValue = form.watch("categoryId")
const subcategoryIdValue = form.watch("subcategoryId")
const isSubmitted = form.formState.isSubmitted
const normalizedQueueName =
typeof queueValue === "string" && queueValue !== "NONE" ? queueValue.toLowerCase() : ""
const isVisitQueue = useMemo(
() => VISIT_KEYWORDS.some((keyword) => normalizedQueueName.includes(keyword)),
[normalizedQueueName]
)
const visitDate = useMemo(() => {
if (!visitDateValue) return null
const timeSegment =
typeof visitTimeValue === "string" && visitTimeValue.length > 0 ? visitTimeValue : "00:00"
const normalizedTime = timeSegment.length === 5 ? `${timeSegment}:00` : timeSegment
try {
return parseISO(`${visitDateValue}T${normalizedTime}`)
} catch {
return null
}
}, [visitDateValue, visitTimeValue])
useEffect(() => {
if (isVisitQueue) return
form.setValue("visitDate", null, { shouldDirty: false, shouldTouch: false })
form.setValue("visitTime", null, { shouldDirty: false, shouldTouch: false })
}, [form, isVisitQueue])
const companyOptions = useMemo(() => {
const map = new Map<string, { id: string; name: string; isAvulso?: boolean; keywords: string[] }>()
companies.forEach((company) => {
const trimmedName = company.name.trim()
const slugFallback = company.slug?.trim()
const label =
trimmedName.length > 0 ? trimmedName : slugFallback && slugFallback.length > 0 ? slugFallback : `Empresa ${company.id.slice(0, 8)}`
map.set(company.id, {
id: company.id,
name: label,
isAvulso: false,
keywords: company.slug ? [company.slug] : [],
})
})
customers.forEach((customer) => {
if (customer.companyId && !map.has(customer.companyId)) {
const trimmedName = customer.companyName?.trim() ?? ""
const label =
trimmedName.length > 0 ? trimmedName : `Empresa ${customer.companyId.slice(0, 8)}`
map.set(customer.companyId, {
id: customer.companyId,
name: label,
isAvulso: customer.companyIsAvulso,
keywords: [],
})
}
})
const base: Array<{ id: string; name: string; isAvulso?: boolean; keywords: string[] }> = [
{ id: NO_COMPANY_VALUE, name: "Sem empresa", keywords: ["sem empresa", "nenhuma"], isAvulso: false },
]
const sorted = Array.from(map.values())
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [...base, ...sorted]
}, [companies, customers])
const filteredCustomers = useMemo(() => {
if (companyValue === NO_COMPANY_VALUE) {
return customers.filter((customer) => !customer.companyId)
}
return customers.filter((customer) => customer.companyId === companyValue)
}, [companyValue, customers])
const companyOptionMap = useMemo(
() => new Map(companyOptions.map((option) => [option.id, option])),
[companyOptions],
)
const companyComboboxOptions = useMemo<SearchableComboboxOption[]>(
() =>
companyOptions.map((option) => ({
value: option.id,
label: option.name,
description: option.isAvulso ? "Empresa avulsa" : undefined,
keywords: option.keywords,
})),
[companyOptions],
)
const selectedCompanyOption = useMemo(
() => companyOptionMap.get(companyValue) ?? null,
[companyOptionMap, companyValue],
)
const requesterById = useMemo(
() => new Map(customers.map((customer) => [customer.id, customer])),
[customers],
)
const selectedRequester = requesterById.get(requesterValue) ?? null
const requesterComboboxOptions = useMemo<SearchableComboboxOption[]>(
() =>
filteredCustomers.map((customer) => ({
value: customer.id,
label: customer.name && customer.name.trim().length > 0 ? customer.name : customer.email,
description: customer.email,
keywords: [
customer.email.toLowerCase(),
customer.companyName?.toLowerCase?.() ?? "",
customer.name?.toLowerCase?.() ?? "",
].filter(Boolean),
})),
[filteredCustomers],
)
const selectTriggerClass = "flex h-8 w-full items-center justify-between rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const selectItemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
const [assigneeInitialized, setAssigneeInitialized] = useState(false)
const allowTicketMentions = useMemo(() => {
const normalized = (role ?? "").toLowerCase()
return normalized === "admin" || normalized === "agent" || normalized === "collaborator"
}, [role])
useEffect(() => {
if (!open) {
setCustomersInitialized(false)
setManualChecklist([])
setAppliedChecklistTemplateIds([])
setChecklistTemplateToApply("")
setChecklistItemText("")
setChecklistItemRequired(true)
form.setValue("companyId", NO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false })
form.setValue("requesterId", "", { shouldDirty: false, shouldTouch: false })
return
}
if (customersInitialized) return
if (!customers.length) return
let initialRequester = form.getValues("requesterId")
if (!initialRequester || !customers.some((customer) => customer.id === initialRequester)) {
if (convexUserId && customers.some((customer) => customer.id === convexUserId)) {
initialRequester = convexUserId
} else {
initialRequester = customers[0].id
}
}
const selected = customers.find((customer) => customer.id === initialRequester) ?? null
form.setValue("requesterId", initialRequester ?? "", { shouldDirty: false, shouldTouch: false })
if (selected?.companyId) {
form.setValue("companyId", selected.companyId, { shouldDirty: false, shouldTouch: false })
} else {
form.setValue("companyId", NO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false })
}
setCustomersInitialized(true)
}, [open, customersInitialized, customers, convexUserId, form])
useEffect(() => {
if (!open || !customersInitialized) return
const options = filteredCustomers
if (options.length === 0) {
if (requesterValue !== "") {
form.setValue("requesterId", "", {
shouldDirty: true,
shouldTouch: true,
shouldValidate: form.formState.isSubmitted,
})
}
return
}
if (!options.some((customer) => customer.id === requesterValue)) {
form.setValue("requesterId", options[0].id, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: form.formState.isSubmitted,
})
}
}, [open, customersInitialized, filteredCustomers, requesterValue, form])
useEffect(() => {
if (requesterValue && form.formState.errors.requesterId) {
form.clearErrors("requesterId")
}
}, [requesterValue, form])
useEffect(() => {
if (!open) {
setAssigneeInitialized(false)
setOpenCalendarField(null)
setVisitDatePickerOpen(false)
return
}
if (assigneeInitialized) return
if (!convexUserId) return
form.setValue("assigneeId", convexUserId, { shouldDirty: false, shouldTouch: false })
setAssigneeInitialized(true)
}, [open, assigneeInitialized, convexUserId, form])
// Default queue to "Chamados" if available when opening
useEffect(() => {
if (!open) return
const current = form.getValues("queueName")
if (current) return
const hasChamados = queues.some((q) => q.name === "Chamados")
if (hasChamados) {
form.setValue("queueName", "Chamados", { shouldDirty: false, shouldTouch: false })
}
}, [open, queues, form])
const handleCategoryChange = (value: string) => {
const previous = form.getValues("categoryId") ?? ""
const next = value ?? ""
form.setValue("categoryId", next, {
shouldDirty: previous !== next && previous !== "",
shouldTouch: true,
shouldValidate: isSubmitted,
})
if (!isSubmitted) {
form.clearErrors("categoryId")
}
}
const handleSubcategoryChange = (value: string) => {
const previous = form.getValues("subcategoryId") ?? ""
const next = value ?? ""
form.setValue("subcategoryId", next, {
shouldDirty: previous !== next && previous !== "",
shouldTouch: true,
shouldValidate: isSubmitted,
})
if (!isSubmitted) {
form.clearErrors("subcategoryId")
}
}
const handleApplyChecklistTemplate = () => {
const templateId = checklistTemplateToApply.trim()
if (!templateId) return
setAppliedChecklistTemplateIds((prev) => {
if (prev.includes(templateId)) return prev
return [...prev, templateId]
})
setChecklistTemplateToApply("")
}
const handleAddChecklistItem = () => {
const text = checklistItemText.trim()
if (!text) {
toast.error("Informe o texto do item do checklist.", { id: "new-ticket" })
return
}
if (text.length > 240) {
toast.error("Item do checklist muito longo (máx. 240 caracteres).", { id: "new-ticket" })
return
}
setManualChecklist((prev) => [...prev, { id: crypto.randomUUID(), text, required: checklistItemRequired }])
setChecklistItemText("")
setChecklistItemRequired(true)
}
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 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." })
setLoading(false)
return
}
const currentQueueName = values.queueName ?? ""
const isVisitQueueOnSubmit =
typeof currentQueueName === "string" &&
VISIT_KEYWORDS.some((keyword) => currentQueueName.toLowerCase().includes(keyword))
if (isVisitQueueOnSubmit) {
if (!values.visitDate) {
form.setError("visitDate", { type: "custom", message: "Informe a data da visita para chamados desta fila." })
return
}
if (!values.visitTime) {
form.setError("visitTime", { type: "custom", message: "Informe o horário da visita para chamados desta fila." })
return
}
}
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
if (selectedForm?.fields?.length) {
const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues)
if (!normalized.ok) {
toast.error(normalized.message, { id: "new-ticket" })
setLoading(false)
return
}
customFieldsPayload = normalized.payload
}
const checklistPayload = manualChecklist.map((item) => ({
text: item.text.trim(),
required: item.required,
}))
const invalidChecklist = checklistPayload.find((item) => item.text.length === 0 || item.text.length > 240)
if (invalidChecklist) {
toast.error("Revise os itens do checklist (texto obrigatório e até 240 caracteres).", { id: "new-ticket" })
return
}
const checklistTemplateIds = appliedChecklistTemplateIds.map((id) => id as Id<"ticketChecklistTemplates">)
setLoading(true)
toast.loading("Criando ticket…", { id: "new-ticket" })
try {
const sel = queues.find((q) => q.name === values.queueName)
const selectedAssignee = form.getValues("assigneeId") ?? null
const requesterToSend = values.requesterId as Id<"users">
let visitDateTimestamp: number | undefined
if (isVisitQueueOnSubmit && values.visitDate && values.visitTime) {
try {
const timeSegment = values.visitTime.length === 5 ? `${values.visitTime}:00` : values.visitTime
const parsed = parseISO(`${values.visitDate}T${timeSegment}`)
visitDateTimestamp = parsed.getTime()
} catch {
visitDateTimestamp = undefined
}
}
const id = await create({
actorId: convexUserId as Id<"users">,
tenantId: DEFAULT_TENANT_ID,
subject: subjectTrimmed,
priority: values.priority,
channel: values.channel,
queueId: sel?.id as Id<"queues"> | undefined,
requesterId: requesterToSend,
assigneeId: selectedAssignee ? (selectedAssignee as Id<"users">) : undefined,
categoryId: values.categoryId as Id<"ticketCategories">,
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
formTemplate: selectedFormKey !== "default" ? selectedFormKey : undefined,
checklist: checklistPayload.length > 0 ? checklistPayload : undefined,
checklistTemplateIds: checklistTemplateIds.length > 0 ? checklistTemplateIds : undefined,
customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined,
visitDate: visitDateTimestamp,
})
const summaryFallback = subjectTrimmed
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : toHtml(summaryFallback)
const MAX_COMMENT_CHARS = 20000
const plainForLimit = (plainDescription.length > 0 ? plainDescription : summaryFallback).trim()
if (plainForLimit.length > MAX_COMMENT_CHARS) {
toast.error(`Descrição muito longa (máx. ${MAX_COMMENT_CHARS} caracteres)`, { id: "new-ticket" })
setLoading(false)
return
}
if (attachments.length > 0 || bodyHtml.trim().length > 0) {
const typedAttachments = attachments.map((a) => ({
storageId: a.storageId as unknown as Id<"_storage">,
name: a.name,
size: a.size,
type: a.type,
}))
await addComment({ ticketId: id as Id<"tickets">, authorId: convexUserId as Id<"users">, visibility: "INTERNAL", body: bodyHtml, attachments: typedAttachments })
}
toast.success("Ticket criado!", { id: "new-ticket" })
setOpen(false)
form.reset({
subject: "",
description: "",
priority: "MEDIUM",
channel: "MANUAL",
queueName: null,
assigneeId: convexUserId ?? null,
categoryId: "",
subcategoryId: "",
visitDate: null,
visitTime: null,
})
form.clearErrors()
setSelectedFormKey("default")
setCustomFieldValues({})
setAssigneeInitialized(false)
setAttachments([])
setManualChecklist([])
setAppliedChecklistTemplateIds([])
setChecklistTemplateToApply("")
setChecklistItemText("")
setChecklistItemRequired(true)
// Navegar para o ticket recém-criado
window.location.href = `/tickets/${id}`
} catch {
toast.error("Não foi possível criar o ticket.", { id: "new-ticket" })
} finally {
setLoading(false)
}
}
const cardTrigger = (
<button
type="button"
className={cn(
"rounded-2xl border border-slate-900 bg-neutral-950 px-4 py-4 text-left text-white shadow-lg shadow-black/30 transition hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40",
"flex h-28 min-w-[220px] flex-1 flex-col justify-between",
triggerClassName
)}
>
<span className="text-xs font-semibold uppercase tracking-wide text-white/60">Atalho</span>
<div className="space-y-1">
<p className="text-lg font-semibold leading-tight">Novo ticket</p>
<p className="text-xs text-white/70">Abrir chamado manualmente</p>
</div>
</button>
)
const buttonTrigger = (
<Button
size="sm"
className={cn(
"rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30",
triggerClassName
)}
>
Novo ticket
</Button>
)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{triggerVariant === "card"
? cardTrigger
: buttonTrigger}
</DialogTrigger>
<DialogContent className="max-w-4xl gap-0 overflow-hidden rounded-3xl border border-slate-200 bg-white p-0 shadow-2xl lg:max-w-5xl">
<div className="max-h-[88vh] overflow-y-auto">
<div className="space-y-5 px-6 pt-7 pb-12 sm:px-8 md:px-10">
<form className="space-y-6" onSubmit={form.handleSubmit(submit)}>
<div className="border-b border-slate-200 pb-5">
<DialogHeader className="gap-1.5 p-0">
<DialogTitle className="text-xl font-semibold text-neutral-900">Novo ticket</DialogTitle>
<DialogDescription className="text-sm text-neutral-600">
Preencha as informações básicas para abrir um chamado.
</DialogDescription>
</DialogHeader>
</div>
{forms.length > 1 ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
<p className="text-sm font-semibold text-neutral-800">Tipo de solicitação</p>
<div className="mt-2 flex flex-wrap gap-2">
{forms.map((formDef) => (
<Button
key={formDef.key}
type="button"
variant={selectedFormKey === formDef.key ? "default" : "outline"}
size="sm"
onClick={() => handleFormSelection(formDef.key)}
>
{formDef.label}
</Button>
))}
</div>
{selectedForm?.description ? (
<p className="mt-2 text-xs text-neutral-500">{selectedForm.description}</p>
) : null}
</div>
) : null}
<FieldSet>
<FieldGroup className="lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<div className="space-y-4">
<Field>
<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>
<Field>
<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, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: form.formState.isSubmitted,
})
}
placeholder="Detalhe o problema, passos para reproduzir, links, etc."
ticketMention={{ enabled: allowTicketMentions }}
/>
<FieldError
errors={
form.formState.errors.description
? [{ message: form.formState.errors.description.message }]
: []
}
/>
</Field>
<Field>
<FieldLabel>Anexos</FieldLabel>
<Dropzone
onUploaded={(files) => setAttachments((prev) => [...prev, ...files])}
className="space-y-1.5 [&>div:first-child]:rounded-2xl [&>div:first-child]:p-4 [&>div:first-child]:pb-5 [&>div:first-child]:shadow-sm"
currentFileCount={attachments.length}
currentTotalBytes={attachmentsTotalBytes}
/>
<FieldError className="mt-1">Formatos comuns de imagem e documentos são aceitos.</FieldError>
</Field>
</div>
<div className="space-y-4">
<Field>
<FieldLabel>Empresa</FieldLabel>
<SearchableCombobox
value={companyValue}
onValueChange={(nextValue) => {
const normalizedValue = nextValue ?? NO_COMPANY_VALUE
const nextCustomers =
normalizedValue === NO_COMPANY_VALUE
? customers.filter((customer) => !customer.companyId)
: customers.filter((customer) => customer.companyId === normalizedValue)
form.setValue("companyId", normalizedValue, {
shouldDirty: normalizedValue !== companyValue,
shouldTouch: true,
})
if (nextCustomers.length === 0) {
form.setValue("requesterId", "", {
shouldDirty: true,
shouldTouch: true,
shouldValidate: form.formState.isSubmitted,
})
} else if (!nextCustomers.some((customer) => customer.id === requesterValue)) {
const fallbackRequester = nextCustomers[0]
form.setValue("requesterId", fallbackRequester.id, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: form.formState.isSubmitted,
})
}
}}
options={companyComboboxOptions}
placeholder="Selecionar empresa"
renderValue={(option) =>
option ? (
<span className="truncate">{option.label}</span>
) : (
<span className="text-muted-foreground">Selecionar empresa</span>
)
}
renderOption={(option) => {
const meta = companyOptionMap.get(option.value)
return (
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col">
<span className="font-medium text-foreground">{option.label}</span>
{meta?.keywords?.length ? (
<span className="text-xs text-muted-foreground">{meta.keywords[0]}</span>
) : null}
</div>
{meta?.isAvulso ? (
<Badge variant="outline" className="rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide">
Avulsa
</Badge>
) : null}
</div>
)
}}
/>
</Field>
<Field>
<FieldLabel className="flex items-center gap-1">
Solicitante <span className="text-destructive">*</span>
</FieldLabel>
<SearchableCombobox
value={requesterValue || null}
onValueChange={(nextValue) => {
if (nextValue === null) {
form.setValue("requesterId", "", {
shouldDirty: true,
shouldTouch: true,
shouldValidate: form.formState.isSubmitted,
})
return
}
if (nextValue !== requesterValue) {
form.setValue("requesterId", nextValue, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: form.formState.isSubmitted,
})
}
const selection = requesterById.get(nextValue)
if (selection) {
const nextCompanyId = selection.companyId ?? NO_COMPANY_VALUE
if (nextCompanyId !== companyValue) {
form.setValue("companyId", nextCompanyId, {
shouldDirty: true,
shouldTouch: true,
})
}
}
}}
options={requesterComboboxOptions}
placeholder={filteredCustomers.length === 0 ? "Nenhum usuário disponível" : "Selecionar solicitante"}
searchPlaceholder="Buscar por nome ou e-mail..."
disabled={filteredCustomers.length === 0}
renderValue={(option) => {
if (!option) return <span className="text-muted-foreground">Selecionar solicitante</span>
return (
<div className="flex w-full flex-wrap items-start gap-2">
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium text-foreground">{option.label}</span>
{option.description ? (
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
) : null}
</div>
{selectedCompanyOption && selectedCompanyOption.id !== NO_COMPANY_VALUE ? (
<Badge
variant="outline"
className="hidden min-w-0 max-w-full truncate rounded-full px-2.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground sm:ml-auto sm:inline-flex sm:max-w-[45%]"
>
{selectedCompanyOption.name}
</Badge>
) : null}
</div>
)
}}
renderOption={(option) => {
const record = requesterById.get(option.value)
const initials = getInitials(record?.name, record?.email ?? option.label)
return (
<div className="flex items-center gap-3">
<Avatar className="size-8 border border-border/60">
<AvatarFallback className="text-xs font-semibold uppercase">{initials}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">{option.label}</p>
<p className="truncate text-xs text-muted-foreground">{record?.email ?? option.description}</p>
</div>
{record?.companyName ? (
<Badge variant="outline" className="hidden rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide md:inline-flex">
{record.companyName}
</Badge>
) : null}
</div>
)
}}
/>
{filteredCustomers.length === 0 ? (
<FieldError className="mt-1">Nenhum colaborador disponível para a empresa selecionada.</FieldError>
) : null}
<FieldError
errors={
form.formState.errors.requesterId
? [{ message: form.formState.errors.requesterId.message }]
: []
}
/>
</Field>
<Field>
<CategorySelectFields
tenantId={DEFAULT_TENANT_ID}
categoryId={categoryIdValue || null}
subcategoryId={subcategoryIdValue || null}
onCategoryChange={handleCategoryChange}
onSubcategoryChange={handleSubcategoryChange}
categoryRequired
subcategoryRequired
layout="stacked"
/>
{form.formState.errors.categoryId?.message || form.formState.errors.subcategoryId?.message ? (
<FieldError className="mt-1 space-y-0.5">
<>
{form.formState.errors.categoryId?.message ? <div>{form.formState.errors.categoryId?.message}</div> : null}
{form.formState.errors.subcategoryId?.message ? <div>{form.formState.errors.subcategoryId?.message}</div> : null}
</>
</FieldError>
) : null}
</Field>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1 xl:gap-4">
<Field>
<FieldLabel>Prioridade</FieldLabel>
<Select value={priorityValue} onValueChange={(v) => form.setValue("priority", v as z.infer<typeof schema>["priority"])}>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Escolha a prioridade" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => (
<SelectItem key={option} value={option} className={selectItemClass}>
<span className="inline-flex items-center gap-2">
<PriorityIcon value={option} />
{priorityStyles[option].label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{/* Canal removido da UI: padrão MANUAL será enviado */}
<Field>
<FieldLabel>Fila</FieldLabel>
<Select value={queueValue} onValueChange={(v) => form.setValue("queueName", v === "NONE" ? null : v)}>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Sem fila" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
<SelectItem value="NONE" className={selectItemClass}>
Sem fila
</SelectItem>
{queues.map((q) => (
<SelectItem key={q.id} value={q.name} className={selectItemClass}>
{q.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{isVisitQueue ? (
<Field>
<FieldLabel className="flex items-center gap-1">
Data e horário da visita <span className="text-destructive">*</span>
</FieldLabel>
<div className="space-y-2">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_140px]">
<Popover open={visitDatePickerOpen} onOpenChange={setVisitDatePickerOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"flex h-8 w-full items-center justify-between rounded-full border border-slate-300 bg-white px-3 text-left text-sm text-neutral-800 shadow-sm hover:bg-slate-50",
!visitDate && "text-muted-foreground"
)}
>
{visitDate
? format(visitDate, "dd/MM/yyyy", { locale: ptBR })
: "Selecione a data"}
<CalendarIcon className="ml-2 size-4 text-neutral-500" />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={visitDate ?? undefined}
onSelect={(date) => {
if (!date) {
form.setValue("visitDate", null, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: form.formState.isSubmitted,
})
form.setValue("visitTime", null, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: form.formState.isSubmitted,
})
return
}
const iso = date.toISOString().slice(0, 10)
form.setValue("visitDate", iso, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: form.formState.isSubmitted,
})
setVisitDatePickerOpen(false)
}}
initialFocus
/>
</PopoverContent>
</Popover>
<div className="flex flex-col gap-1">
<TimePicker
value={typeof visitTimeValue === "string" ? visitTimeValue : ""}
onChange={(value) =>
form.setValue("visitTime", value || null, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: form.formState.isSubmitted,
})
}
stepMinutes={5}
className="h-8 rounded-full border border-slate-300 bg-white text-sm font-medium text-neutral-800 shadow-sm"
/>
<FieldError
errors={
form.formState.errors.visitTime
? [{ message: form.formState.errors.visitTime.message as string }]
: []
}
/>
</div>
</div>
<FieldError
errors={
form.formState.errors.visitDate
? [{ message: form.formState.errors.visitDate.message as string }]
: []
}
/>
</div>
</Field>
) : null}
<Field>
<FieldLabel>Responsável</FieldLabel>
<Select
value={assigneeSelectValue}
onValueChange={(value) =>
form.setValue("assigneeId", value === "NONE" ? null : value, {
shouldDirty: value !== assigneeValue,
shouldTouch: true,
})
}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder={staff.length === 0 ? "Carregando..." : "Selecione o responsável"} />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
<SelectItem value="NONE" className={selectItemClass}>
Sem responsável
</SelectItem>
{staff.map((member) => (
<SelectItem key={member._id} value={member._id} className={selectItemClass}>
{member.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
</div>
{selectedForm.fields.length > 0 ? (
<div className="grid gap-4 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:grid-cols-2 lg:col-span-2">
<p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p>
{selectedForm.fields.map((field) => {
const value = customFieldValues[field.id]
const fieldId = `custom-field-${field.id}`
const isRequiredStar = field.required && field.key !== "colaborador_patrimonio"
const labelSuffix = isRequiredStar ? <span className="text-destructive">*</span> : null
const helpText = field.description ? (
<p className="text-xs text-neutral-500">{field.description}</p>
) : null
const shouldUseTextarea = field.key.includes("observacao") || field.key.includes("permissao")
const spanClass = shouldUseTextarea || field.type === "boolean" ? "sm:col-span-2" : ""
if (field.type === "boolean") {
return (
<div
key={field.id}
className={cn(
"flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2",
spanClass,
"sm:col-span-2"
)}
>
<input
id={fieldId}
type="checkbox"
className="size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40"
checked={Boolean(value)}
onChange={(event) => handleCustomFieldChange(field, event.target.checked)}
/>
<div className="flex flex-col">
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
{field.label} {labelSuffix}
</label>
{helpText}
</div>
</div>
)
}
if (field.type === "select") {
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Select
value={typeof value === "string" ? value : ""}
onValueChange={(selected) => handleCustomFieldChange(field, selected)}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value} className={selectItemClass}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{helpText}
</Field>
)
}
if (field.type === "number") {
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
type="number"
inputMode="decimal"
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
}
if (field.type === "date") {
const parsedDate =
typeof value === "string" && value ? parseISO(value) : undefined
const isValidDate = Boolean(parsedDate && !Number.isNaN(parsedDate.getTime()))
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Popover
open={openCalendarField === field.id}
onOpenChange={(open) => setOpenCalendarField(open ? field.id : null)}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
"w-full justify-between gap-2 text-left font-normal",
!isValidDate && "text-muted-foreground"
)}
>
<span>
{isValidDate
? format(parsedDate as Date, "dd/MM/yyyy", { locale: ptBR })
: "Selecionar data"}
</span>
<CalendarIcon className="size-4 text-muted-foreground" aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={isValidDate ? (parsedDate as Date) : undefined}
onSelect={(selected) => {
handleCustomFieldChange(
field,
selected ? format(selected, "yyyy-MM-dd") : ""
)
setOpenCalendarField(null)
}}
initialFocus
captionLayout="dropdown"
startMonth={new Date(1900, 0)}
endMonth={new Date(new Date().getFullYear() + 5, 11)}
locale={ptBR}
timeZone={calendarTimeZone}
/>
</PopoverContent>
</Popover>
{helpText}
</Field>
)
}
if (shouldUseTextarea) {
return (
<Field key={field.id} className={cn("flex-col", spanClass, "sm:col-span-2")}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<textarea
id={fieldId}
className="min-h-[90px] rounded-lg border border-slate-300 px-3 py-2 text-sm text-neutral-800 shadow-sm focus:border-neutral-900 focus:outline-none focus:ring-2 focus:ring-neutral-900/10"
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
}
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
})}
</div>
) : null}
</FieldGroup>
<div className="mt-6 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="flex items-center gap-2 text-sm font-semibold text-neutral-900">
<ListChecks className="size-4 text-neutral-700" />
Checklist (opcional)
</p>
<p className="mt-1 text-xs text-neutral-500">
Itens obrigatórios bloqueiam o encerramento do ticket até serem concluídos.
</p>
</div>
{(checklistTemplates ?? []).length > 0 ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Select value={checklistTemplateToApply} onValueChange={setChecklistTemplateToApply}>
<SelectTrigger className="h-9 w-full sm:w-64">
<SelectValue placeholder="Aplicar template..." />
</SelectTrigger>
<SelectContent className="rounded-xl">
{(checklistTemplates ?? []).map((tpl) => (
<SelectItem key={tpl.id} value={String(tpl.id)}>
{tpl.name}
{tpl.company ? `${tpl.company.name}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
className="h-9"
onClick={handleApplyChecklistTemplate}
disabled={
!checklistTemplateToApply.trim() ||
appliedChecklistTemplateIds.includes(checklistTemplateToApply.trim())
}
>
Aplicar
</Button>
</div>
) : null}
</div>
{appliedChecklistTemplates.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{appliedChecklistTemplates.map((tpl) => (
<Badge key={tpl.id} variant="secondary" className="flex items-center gap-2 rounded-full">
<span className="truncate">{tpl.name}</span>
<button
type="button"
className="text-xs text-neutral-600 hover:text-neutral-900"
onClick={() =>
setAppliedChecklistTemplateIds((prev) => prev.filter((id) => id !== String(tpl.id)))
}
>
×
</button>
</Badge>
))}
</div>
) : null}
{checklistTemplatePreviewItems.length > 0 || manualChecklist.length > 0 ? (
<div className="mt-4 space-y-2">
{checklistTemplatePreviewItems.map((item) => (
<div
key={item.key}
className="flex items-start justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm text-neutral-800" title={item.text}>
{item.text}
</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge variant={item.required ? "secondary" : "outline"} className="rounded-full text-[11px]">
{item.required ? "Obrigatório" : "Opcional"}
</Badge>
<Badge variant="outline" className="rounded-full text-[11px]">
Template: {item.templateName}
</Badge>
</div>
</div>
</div>
))}
{manualChecklist.map((item) => (
<div
key={item.id}
className="flex flex-col gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 sm:flex-row sm:items-center"
>
<Input
value={item.text}
onChange={(e) =>
setManualChecklist((prev) =>
prev.map((row) => (row.id === item.id ? { ...row, text: e.target.value } : row))
)
}
placeholder="Item do checklist..."
className="h-9 flex-1"
/>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<Checkbox
checked={item.required}
onCheckedChange={(checked) =>
setManualChecklist((prev) =>
prev.map((row) =>
row.id === item.id ? { ...row, required: Boolean(checked) } : row
)
)
}
/>
Obrigatório
</label>
<Button
type="button"
variant="ghost"
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))}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
) : (
<div className="mt-3 rounded-xl border border-dashed border-slate-200 p-4 text-sm text-neutral-600">
Adicione itens ou aplique um template para criar um checklist neste ticket.
</div>
)}
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
value={checklistItemText}
onChange={(e) => setChecklistItemText(e.target.value)}
placeholder="Adicionar item do checklist..."
className="h-9 flex-1"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleAddChecklistItem()
}
}}
/>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<Checkbox
checked={checklistItemRequired}
onCheckedChange={(checked) => setChecklistItemRequired(Boolean(checked))}
/>
Obrigatório
</label>
<Button type="button" onClick={handleAddChecklistItem} size="sm" className="ml-2 gap-2">
<Plus className="size-4" />
Adicionar
</Button>
</div>
</div>
</FieldSet>
<div className="flex justify-end border-t border-slate-200 pt-5">
<Button
type="submit"
disabled={loading}
className="inline-flex min-w-[140px] items-center justify-center gap-2 rounded-lg border border-black bg-black px-6 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 disabled:opacity-60"
>
{loading ? (
<>
<Spinner className="me-2" /> Criando
</>
) : (
"Criar"
)}
</Button>
</div>
</form>
</div>
</div>
</DialogContent>
</Dialog>
)
}