@@ -1556,7 +1684,9 @@ async function handleDeleteUser() {
+ ) : null}
+ {tabs.includes("invites") ? (
@@ -1813,7 +1943,51 @@ async function handleDeleteUser() {
+ ) : null}
+
diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx
index 5118f0a..2c65216 100644
--- a/src/components/app-sidebar.tsx
+++ b/src/components/app-sidebar.tsx
@@ -93,7 +93,7 @@ const navigation: NavigationGroup[] = [
requiredRole: "admin",
items: [
{
- title: "Acessos",
+ title: "Administração",
url: "/admin",
icon: UserPlus,
requiredRole: "admin",
diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx
index 08beb67..b52b0d2 100644
--- a/src/components/settings/settings-content.tsx
+++ b/src/components/settings/settings-content.tsx
@@ -60,10 +60,10 @@ const SETTINGS_ACTIONS: SettingsAction[] = [
icon: Layers3,
},
{
- title: "Acessos",
- description: "Convide novos usuários, revise papéis e acompanhe quem tem acesso ao workspace.",
+ title: "Equipe e convites",
+ description: "Convide novos usuários, gerencie papéis e acompanhe quem tem acesso ao workspace.",
href: "/admin",
- cta: "Abrir painel",
+ cta: "Abrir administração",
requiredRole: "admin",
icon: UserPlus,
},
diff --git a/src/components/tickets/new-ticket-dialog.tsx b/src/components/tickets/new-ticket-dialog.tsx
index cc0ef04..8175197 100644
--- a/src/components/tickets/new-ticket-dialog.tsx
+++ b/src/components/tickets/new-ticket-dialog.tsx
@@ -19,11 +19,13 @@ 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,
- priorityStyles,
-} from "@/components/tickets/priority-select"
+import { PriorityIcon, priorityStyles } 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 { useDefaultQueues } from "@/hooks/use-default-queues"
+import { cn } from "@/lib/utils"
type CustomerOption = {
id: string
@@ -36,12 +38,58 @@ type CustomerOption = {
avatarUrl: string | null
}
-const ALL_COMPANIES_VALUE = "__all__"
-const NO_COMPANY_VALUE = "__no_company__"
-const NO_REQUESTER_VALUE = "__no_requester__"
+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() : "?"
+}
-import { useDefaultQueues } from "@/hooks/use-default-queues"
-import { cn } from "@/lib/utils"
+type RequesterPreviewProps = {
+ customer: CustomerOption | null
+ company: { id: string; name: string; isAvulso?: boolean } | null
+}
+
+function RequesterPreview({ customer, company }: RequesterPreviewProps) {
+ if (!customer) {
+ return (
+
+ Selecione um solicitante para visualizar os detalhes aqui.
+
+ )
+ }
+
+ const initials = getInitials(customer.name, customer.email)
+ const companyLabel = customer.companyName ?? company?.name ?? "Sem empresa"
+
+ return (
+
+
+
+ {initials}
+
+
+
{customer.name || customer.email}
+
{customer.email}
+
+
+
+
+ {companyLabel}
+
+
+
+ )
+}
+
+const NO_COMPANY_VALUE = "__no_company__"
+const AUTO_COMPANY_VALUE = "__auto__"
const schema = z.object({
subject: z.string().default(""),
@@ -70,7 +118,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
channel: "MANUAL",
queueName: null,
assigneeId: null,
- companyId: ALL_COMPANIES_VALUE,
+ companyId: AUTO_COMPANY_VALUE,
requesterId: "",
categoryId: "",
subcategoryId: "",
@@ -132,44 +180,90 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
const queueValue = form.watch("queueName") ?? "NONE"
const assigneeValue = form.watch("assigneeId") ?? null
const assigneeSelectValue = assigneeValue ?? "NONE"
- const companyValue = form.watch("companyId") ?? ALL_COMPANIES_VALUE
+ const companyValue = form.watch("companyId") ?? AUTO_COMPANY_VALUE
const requesterValue = form.watch("requesterId") ?? ""
const categoryIdValue = form.watch("categoryId")
const subcategoryIdValue = form.watch("subcategoryId")
const isSubmitted = form.formState.isSubmitted
const companyOptions = useMemo(() => {
- const map = new Map
()
+ const map = new Map()
companies.forEach((company) => {
- map.set(company.id, { id: company.id, name: company.name, isAvulso: false })
+ map.set(company.id, {
+ id: company.id,
+ name: company.name.trim().length > 0 ? company.name : "Empresa sem nome",
+ isAvulso: false,
+ keywords: company.slug ? [company.slug] : [],
+ })
})
customers.forEach((customer) => {
if (customer.companyId && !map.has(customer.companyId)) {
map.set(customer.companyId, {
id: customer.companyId,
- name: customer.companyName ?? "Empresa sem nome",
+ name: customer.companyName && customer.companyName.trim().length > 0 ? customer.companyName : "Empresa sem nome",
isAvulso: customer.companyIsAvulso,
+ keywords: [],
})
}
})
- const includeNoCompany = customers.some((customer) => !customer.companyId)
- const result: Array<{ id: string; name: string; isAvulso?: boolean }> = [
- { id: ALL_COMPANIES_VALUE, name: "Todas as empresas" },
+ const base: Array<{ id: string; name: string; isAvulso?: boolean; keywords: string[] }> = [
+ { id: NO_COMPANY_VALUE, name: "Sem empresa", keywords: ["sem empresa", "nenhuma"], isAvulso: false },
]
- if (includeNoCompany) {
- result.push({ id: NO_COMPANY_VALUE, name: "Sem empresa" })
- }
const sorted = Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
- return [...result, ...sorted]
+ return [...base, ...sorted]
}, [companies, customers])
const filteredCustomers = useMemo(() => {
- if (companyValue === ALL_COMPANIES_VALUE) return customers
+ if (companyValue === AUTO_COMPANY_VALUE) return customers
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(
+ () =>
+ companyOptions.map((option) => ({
+ value: option.id,
+ label: option.name,
+ description: option.isAvulso ? "Empresa avulsa" : undefined,
+ keywords: option.keywords,
+ })),
+ [companyOptions],
+ )
+
+ const selectedCompanyOption = useMemo(() => {
+ if (companyValue === AUTO_COMPANY_VALUE) return null
+ const key = companyValue === NO_COMPANY_VALUE ? NO_COMPANY_VALUE : companyValue
+ return companyOptionMap.get(key) ?? null
+ }, [companyOptionMap, companyValue])
+
+ const requesterById = useMemo(
+ () => new Map(customers.map((customer) => [customer.id, customer])),
+ [customers],
+ )
+
+ const selectedRequester = requesterById.get(requesterValue) ?? null
+
+ const requesterComboboxOptions = useMemo(
+ () =>
+ 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)
@@ -181,7 +275,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
useEffect(() => {
if (!open) {
setCustomersInitialized(false)
- form.setValue("companyId", ALL_COMPANIES_VALUE, { shouldDirty: false, shouldTouch: false })
+ form.setValue("companyId", AUTO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false })
form.setValue("requesterId", "", { shouldDirty: false, shouldTouch: false })
return
}
@@ -199,10 +293,8 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
form.setValue("requesterId", initialRequester ?? "", { shouldDirty: false, shouldTouch: false })
if (selected?.companyId) {
form.setValue("companyId", selected.companyId, { shouldDirty: false, shouldTouch: false })
- } else if (selected) {
- form.setValue("companyId", NO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false })
} else {
- form.setValue("companyId", ALL_COMPANIES_VALUE, { shouldDirty: false, shouldTouch: false })
+ form.setValue("companyId", selected ? NO_COMPANY_VALUE : AUTO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false })
}
setCustomersInitialized(true)
}, [open, customersInitialized, customers, convexUserId, form])
@@ -465,72 +557,137 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
Empresa
-
-
-
-
- Solicitante *
-
-
+ Selecionar empresa
+ )
+ }
+ renderOption={(option) => {
+ const meta = companyOptionMap.get(option.value)
+ return (
+
+
+ {option.label}
+ {meta?.keywords?.length ? (
+ {meta.keywords[0]}
+ ) : null}
+
+ {meta?.isAvulso ? (
+
+ Avulsa
+
+ ) : null}
+
+ )
+ }}
+ />
+
+
+
+ Solicitante *
+
+
+ {
+ 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) =>
+ option ? (
+
+ {option.label}
+ {option.description ? (
+ {option.description}
+ ) : null}
+
+ ) : (
+ Selecionar solicitante
+ )
+ }
+ renderOption={(option) => {
+ const record = requesterById.get(option.value)
+ const initials = getInitials(record?.name, record?.email ?? option.label)
+ return (
+
+
+ {initials}
+
+
+
{option.label}
+
{record?.email ?? option.description}
+
+ {record?.companyName ? (
+
+ {record.companyName}
+
+ ) : null}
+
+ )
+ }}
+ />
{filteredCustomers.length === 0 ? (
Nenhum colaborador disponível para a empresa selecionada.
) : null}
diff --git a/src/components/ui/searchable-combobox.tsx b/src/components/ui/searchable-combobox.tsx
new file mode 100644
index 0000000..80a8fb8
--- /dev/null
+++ b/src/components/ui/searchable-combobox.tsx
@@ -0,0 +1,174 @@
+"use client"
+
+import { useEffect, useMemo, useState } from "react"
+import { ChevronsUpDown, Check, X } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Separator } from "@/components/ui/separator"
+import { cn } from "@/lib/utils"
+
+export type SearchableComboboxOption = {
+ value: string
+ label: string
+ description?: string
+ keywords?: string[]
+ disabled?: boolean
+}
+
+type SearchableComboboxProps = {
+ value: string | null
+ onValueChange: (value: string | null) => void
+ options: SearchableComboboxOption[]
+ placeholder?: string
+ searchPlaceholder?: string
+ emptyText?: string
+ className?: string
+ disabled?: boolean
+ allowClear?: boolean
+ clearLabel?: string
+ renderValue?: (option: SearchableComboboxOption | null) => React.ReactNode
+ renderOption?: (option: SearchableComboboxOption, active: boolean) => React.ReactNode
+}
+
+export function SearchableCombobox({
+ value,
+ onValueChange,
+ options,
+ placeholder = "Selecionar...",
+ searchPlaceholder = "Buscar...",
+ emptyText = "Nenhuma opção encontrada.",
+ className,
+ disabled,
+ allowClear = false,
+ clearLabel = "Limpar seleção",
+ renderValue,
+ renderOption,
+}: SearchableComboboxProps) {
+ const [open, setOpen] = useState(false)
+ const [search, setSearch] = useState("")
+
+ const selected = useMemo(() => {
+ if (value === null) return null
+ return options.find((option) => option.value === value) ?? null
+ }, [options, value])
+
+ const filtered = useMemo(() => {
+ const term = search.trim().toLowerCase()
+ if (!term) {
+ return options
+ }
+ return options.filter((option) => {
+ const labelMatch = option.label.toLowerCase().includes(term)
+ const descriptionMatch = option.description?.toLowerCase().includes(term) ?? false
+ const keywordMatch = option.keywords?.some((keyword) => keyword.toLowerCase().includes(term)) ?? false
+ return labelMatch || descriptionMatch || keywordMatch
+ })
+ }, [options, search])
+
+ useEffect(() => {
+ if (!open) {
+ setSearch("")
+ }
+ }, [open])
+
+ const handleSelect = (nextValue: string) => {
+ if (nextValue === value) {
+ setOpen(false)
+ return
+ }
+ onValueChange(nextValue)
+ setOpen(false)
+ }
+
+ return (
+
+
+
+
+
+
+ setSearch(event.target.value)}
+ placeholder={searchPlaceholder}
+ className="h-9"
+ />
+
+ {allowClear ? (
+ <>
+
+
+ >
+ ) : null}
+
+ {filtered.length === 0 ? (
+ {emptyText}
+ ) : (
+
+ {filtered.map((option) => {
+ const isActive = option.value === value
+ const content = renderOption ? (
+ renderOption(option, isActive)
+ ) : (
+
+ {option.label}
+ {option.description ? {option.description} : null}
+
+ )
+ return (
+
+ )
+ })}
+
+ )}
+
+
+
+ )
+}
+