diff --git a/convex/tickets.ts b/convex/tickets.ts index 01f30a8..83bf349 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -1550,6 +1550,95 @@ export const changeAssignee = mutation({ }, }); +export const changeRequester = mutation({ + args: { ticketId: v.id("tickets"), requesterId: v.id("users"), actorId: v.id("users") }, + handler: async (ctx, { ticketId, requesterId, actorId }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) + const viewerRole = (viewer.role ?? "AGENT").toUpperCase() + const actor = viewer.user + + if (String(ticketDoc.requesterId) === String(requesterId)) { + return { status: "unchanged" } + } + + const requester = (await ctx.db.get(requesterId)) as Doc<"users"> | null + if (!requester || requester.tenantId !== ticketDoc.tenantId) { + throw new ConvexError("Solicitante inválido") + } + + if (viewerRole === "MANAGER") { + if (!actor.companyId) { + throw new ConvexError("Gestor não possui empresa vinculada") + } + if (requester.companyId !== actor.companyId) { + throw new ConvexError("Gestores só podem alterar para usuários da própria empresa") + } + } + + const now = Date.now() + const requesterSnapshot = { + name: requester.name, + email: requester.email, + avatarUrl: requester.avatarUrl ?? undefined, + teams: requester.teams ?? undefined, + } + + let companyId: Id<"companies"> | undefined + let companySnapshot: { name: string; slug?: string; isAvulso?: boolean } | undefined + + if (requester.companyId) { + const company = await ctx.db.get(requester.companyId) + if (company) { + companyId = company._id as Id<"companies"> + companySnapshot = { + name: company.name, + slug: company.slug ?? undefined, + isAvulso: company.isAvulso ?? undefined, + } + } + } + + const patch: Record = { + requesterId, + requesterSnapshot, + updatedAt: now, + } + if (companyId) { + patch["companyId"] = companyId + patch["companySnapshot"] = companySnapshot + } else { + patch["companyId"] = undefined + patch["companySnapshot"] = undefined + } + + await ctx.db.patch(ticketId, patch) + + await ctx.db.insert("ticketEvents", { + ticketId, + type: "REQUESTER_CHANGED", + payload: { + requesterId, + requesterName: requester.name, + requesterEmail: requester.email, + companyId: companyId ?? null, + companyName: companySnapshot?.name ?? null, + actorId, + actorName: actor.name, + actorAvatar: actor.avatarUrl, + }, + createdAt: now, + }) + + return { status: "updated" } + }, +}) + + export const changeQueue = mutation({ args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") }, handler: async (ctx, { ticketId, queueId, actorId }) => { diff --git a/convex/users.ts b/convex/users.ts index dae803d..9edf113 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,8 +1,10 @@ import { mutation, query } from "./_generated/server"; import { ConvexError, v } from "convex/values"; -import { requireAdmin } from "./rbac"; +import type { Id } from "./_generated/dataModel"; +import { requireAdmin, requireStaff } from "./rbac"; const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]); +const CUSTOMER_ROLES = new Set(["COLLABORATOR", "MANAGER"]); export const ensureUser = mutation({ args: { @@ -88,6 +90,74 @@ export const listAgents = query({ }, }); +export const listCustomers = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + const viewer = await requireStaff(ctx, viewerId, tenantId) + const viewerRole = (viewer.role ?? "AGENT").toUpperCase() + let managerCompanyId: Id<"companies"> | null = null + if (viewerRole === "MANAGER") { + managerCompanyId = viewer.user.companyId ?? null + if (!managerCompanyId) { + throw new ConvexError("Gestor não possui empresa vinculada") + } + } + + const users = await ctx.db + .query("users") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + const allowed = users.filter((user) => { + const role = (user.role ?? "COLLABORATOR").toUpperCase() + if (!CUSTOMER_ROLES.has(role)) return false + if (managerCompanyId && user.companyId !== managerCompanyId) return false + return true + }) + + const companyIds = Array.from( + new Set( + allowed + .map((user) => user.companyId) + .filter((companyId): companyId is Id<"companies"> => Boolean(companyId)) + ) + ) + + const companyMap = new Map() + if (companyIds.length > 0) { + await Promise.all( + companyIds.map(async (companyId) => { + const company = await ctx.db.get(companyId) + if (company) { + companyMap.set(String(companyId), { + name: company.name, + isAvulso: company.isAvulso ?? undefined, + }) + } + }) + ) + } + + return allowed + .map((user) => { + const companyId = user.companyId ? String(user.companyId) : null + const company = companyId ? companyMap.get(companyId) ?? null : null + return { + id: String(user._id), + name: user.name, + email: user.email, + role: (user.role ?? "COLLABORATOR").toUpperCase(), + companyId, + companyName: company?.name ?? null, + companyIsAvulso: Boolean(company?.isAvulso), + avatarUrl: user.avatarUrl ?? null, + } + }) + .sort((a, b) => a.name.localeCompare(b.name ?? "", "pt-BR")) + }, +}) + + export const findByEmail = query({ args: { tenantId: v.string(), email: v.string() }, handler: async (ctx, { tenantId, email }) => { diff --git a/src/app/tickets/new/page.tsx b/src/app/tickets/new/page.tsx index 8a39268..eeec6d4 100644 --- a/src/app/tickets/new/page.tsx +++ b/src/app/tickets/new/page.tsx @@ -26,6 +26,22 @@ import { } from "@/components/tickets/priority-select" import { CategorySelectFields } from "@/components/tickets/category-select" +type CustomerOption = { + id: string + name: string + email: string + role: string + companyId: string | null + companyName: string | null + companyIsAvulso: boolean + avatarUrl: string | null +} + +const ALL_COMPANIES_VALUE = "__all__" +const NO_COMPANY_VALUE = "__no_company__" +const NO_REQUESTER_VALUE = "__no_requester__" + + export default function NewTicketPage() { const router = useRouter() const { convexUserId, isStaff, role } = useAuth() @@ -46,6 +62,31 @@ export default function NewTicketPage() { [staffRaw] ) + const directoryQueryEnabled = queuesEnabled && Boolean(convexUserId) + const companiesRaw = useQuery( + directoryQueryEnabled ? api.companies.list : "skip", + directoryQueryEnabled + ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } + : "skip" + ) as Array<{ id: string; name: string; slug?: string | null }> | undefined + const companies = useMemo( + () => + (companiesRaw ?? []).map((company) => ({ + id: String(company.id), + name: company.name, + slug: company.slug ?? null, + })), + [companiesRaw] + ) + + const customersRaw = useQuery( + directoryQueryEnabled ? api.users.listCustomers : "skip", + directoryQueryEnabled + ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } + : "skip" + ) as CustomerOption[] | undefined + const customers = useMemo(() => customersRaw ?? [], [customersRaw]) + const [subject, setSubject] = useState("") const [summary, setSummary] = useState("") const [priority, setPriority] = useState("MEDIUM") @@ -62,9 +103,91 @@ export default function NewTicketPage() { const [descriptionError, setDescriptionError] = useState(null) const [assigneeInitialized, setAssigneeInitialized] = useState(false) + const [companyFilter, setCompanyFilter] = useState(ALL_COMPANIES_VALUE) + const [requesterId, setRequesterId] = useState(convexUserId) + const [requesterError, setRequesterError] = useState(null) + const [customersInitialized, setCustomersInitialized] = useState(false) + const queueOptions = useMemo(() => queues.map((q) => q.name), [queues]) const assigneeSelectValue = assigneeId ?? "NONE" + const companyOptions = useMemo(() => { + const map = new Map() + companies.forEach((company) => { + map.set(company.id, { id: company.id, name: company.name, isAvulso: false }) + }) + customers.forEach((customer) => { + if (customer.companyId && !map.has(customer.companyId)) { + map.set(customer.companyId, { + id: customer.companyId, + name: customer.companyName ?? "Empresa sem nome", + isAvulso: customer.companyIsAvulso, + }) + } + }) + const includeNoCompany = customers.some((customer) => !customer.companyId) + const result: Array<{ id: string; name: string; isAvulso?: boolean }> = [ + { id: ALL_COMPANIES_VALUE, name: "Todas as empresas" }, + ] + 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] + }, [companies, customers]) + + const filteredCustomers = useMemo(() => { + if (companyFilter === ALL_COMPANIES_VALUE) return customers + if (companyFilter === NO_COMPANY_VALUE) { + return customers.filter((customer) => !customer.companyId) + } + return customers.filter((customer) => customer.companyId === companyFilter) + }, [companyFilter, customers]) + + useEffect(() => { + if (customersInitialized) return + if (!customers.length) return + let initialRequester = requesterId + if (!initialRequester || !customers.some((customer) => customer.id === initialRequester)) { + if (convexUserId && customers.some((customer) => customer.id === convexUserId)) { + initialRequester = convexUserId + } else { + initialRequester = customers[0]?.id ?? null + } + } + if (initialRequester) { + setRequesterId(initialRequester) + const selected = customers.find((customer) => customer.id === initialRequester) + if (selected?.companyId) { + setCompanyFilter(selected.companyId) + } else if (selected) { + setCompanyFilter(NO_COMPANY_VALUE) + } + } + setCustomersInitialized(true) + }, [customersInitialized, customers, requesterId, convexUserId]) + + useEffect(() => { + if (!customersInitialized) return + const available = filteredCustomers + if (available.length === 0) { + if (requesterId !== null) { + setRequesterId(null) + } + return + } + if (!requesterId || !available.some((customer) => customer.id === requesterId)) { + setRequesterId(available[0].id) + } + }, [filteredCustomers, customersInitialized, requesterId]) + + useEffect(() => { + if (requesterId && requesterError) { + setRequesterError(null) + } + }, [requesterId, requesterError]) + + useEffect(() => { if (assigneeInitialized) return if (!convexUserId) return @@ -112,12 +235,19 @@ export default function NewTicketPage() { } setDescriptionError(null) + if (!requesterId) { + setRequesterError("Selecione um solicitante.") + toast.error("Selecione um solicitante para o chamado.") + return + } + setLoading(true) toast.loading("Criando ticket...", { id: "create-ticket" }) try { const selQueue = queues.find((q) => q.name === queueName) const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined const assigneeToSend = assigneeId ? (assigneeId as Id<"users">) : undefined + const requesterToSend = requesterId as Id<"users"> const id = await create({ actorId: convexUserId as Id<"users">, tenantId: DEFAULT_TENANT_ID, @@ -126,7 +256,7 @@ export default function NewTicketPage() { priority, channel, queueId, - requesterId: convexUserId as Id<"users">, + requesterId: requesterToSend, assigneeId: assigneeToSend, categoryId: categoryId as Id<"ticketCategories">, subcategoryId: subcategoryId as Id<"ticketSubcategories">, @@ -210,6 +340,76 @@ export default function NewTicketPage() { /> {descriptionError ?

{descriptionError}

: null} +
+
+ + +
+
+ + + {filteredCustomers.length === 0 ? ( +

+ Nenhum colaborador disponível para a empresa selecionada. +

+ ) : null} + {requesterError ?

{requesterError}

: null} +
+
+
(staffRaw ?? []).sort((a, b) => a.name.localeCompare(b.name, "pt-BR")), [staffRaw] ) + + const directoryQueryEnabled = queuesEnabled && Boolean(convexUserId) + const companiesRaw = useQuery( + directoryQueryEnabled ? api.companies.list : "skip", + directoryQueryEnabled + ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } + : "skip" + ) as Array<{ id: string; name: string; slug?: string | null }> | undefined + const companies = useMemo( + () => + (companiesRaw ?? []).map((company) => ({ + id: String(company.id), + name: company.name, + slug: company.slug ?? null, + })), + [companiesRaw] + ) + + const customersRaw = useQuery( + directoryQueryEnabled ? api.users.listCustomers : "skip", + directoryQueryEnabled + ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } + : "skip" + ) as CustomerOption[] | undefined + const customers = useMemo(() => customersRaw ?? [], [customersRaw]) const [attachments, setAttachments] = useState>([]) + const [customersInitialized, setCustomersInitialized] = useState(false) const attachmentsTotalBytes = useMemo( () => attachments.reduce((acc, item) => acc + (item.size ?? 0), 0), [attachments] @@ -86,9 +132,44 @@ 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 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() + companies.forEach((company) => { + map.set(company.id, { id: company.id, name: company.name, isAvulso: false }) + }) + customers.forEach((customer) => { + if (customer.companyId && !map.has(customer.companyId)) { + map.set(customer.companyId, { + id: customer.companyId, + name: customer.companyName ?? "Empresa sem nome", + isAvulso: customer.companyIsAvulso, + }) + } + }) + const includeNoCompany = customers.some((customer) => !customer.companyId) + const result: Array<{ id: string; name: string; isAvulso?: boolean }> = [ + { id: ALL_COMPANIES_VALUE, name: "Todas as empresas" }, + ] + 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] + }, [companies, customers]) + + const filteredCustomers = useMemo(() => { + if (companyValue === ALL_COMPANIES_VALUE) return customers + if (companyValue === NO_COMPANY_VALUE) { + return customers.filter((customer) => !customer.companyId) + } + return customers.filter((customer) => customer.companyId === companyValue) + }, [companyValue, customers]) + 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) @@ -97,6 +178,64 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin return normalized === "admin" || normalized === "agent" || normalized === "collaborator" }, [role]) + useEffect(() => { + if (!open) { + setCustomersInitialized(false) + form.setValue("companyId", ALL_COMPANIES_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 if (selected) { + form.setValue("companyId", NO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false }) + } else { + form.setValue("companyId", ALL_COMPANIES_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) @@ -166,6 +305,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin try { const sel = queues.find((q) => q.name === values.queueName) const selectedAssignee = form.getValues("assigneeId") ?? null + const requesterToSend = values.requesterId as Id<"users"> const id = await create({ actorId: convexUserId as Id<"users">, tenantId: DEFAULT_TENANT_ID, @@ -174,7 +314,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin priority: values.priority, channel: values.channel, queueId: sel?.id as Id<"queues"> | undefined, - requesterId: convexUserId as Id<"users">, + requesterId: requesterToSend, assigneeId: selectedAssignee ? (selectedAssignee as Id<"users">) : undefined, categoryId: values.categoryId as Id<"ticketCategories">, subcategoryId: values.subcategoryId as Id<"ticketSubcategories">, @@ -323,6 +463,86 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
+ + Empresa + + + + + Solicitante * + + + {filteredCustomers.length === 0 ? ( + Nenhum colaborador disponível para a empresa selecionada. + ) : null} + + + [] | undefined) ?? [] const queuesEnabled = Boolean(isStaff && convexUserId) + const companiesRaw = useQuery( + convexUserId ? api.companies.list : "skip", + convexUserId + ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } + : "skip" + ) as Array<{ id: string; name: string; slug?: string | null }> | undefined + const companies = useMemo( + () => + (companiesRaw ?? []).map((company) => ({ + id: String(company.id), + name: company.name, + slug: company.slug ?? null, + })), + [companiesRaw] + ) + + const customersRaw = useQuery( + convexUserId ? api.users.listCustomers : "skip", + convexUserId + ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } + : "skip" + ) as CustomerOption[] | undefined + const customers = useMemo(() => customersRaw ?? [], [customersRaw]) + const queueArgs = queuesEnabled ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } : "skip" @@ -210,6 +250,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { const [closeOpen, setCloseOpen] = useState(false) const [assigneeChangeReason, setAssigneeChangeReason] = useState("") const [assigneeReasonError, setAssigneeReasonError] = useState(null) + const [companySelection, setCompanySelection] = useState(ALL_COMPANIES_VALUE) + const [requesterSelection, setRequesterSelection] = useState(ticket.requester.id) + const [requesterError, setRequesterError] = useState(null) + const [customersInitialized, setCustomersInitialized] = useState(false) const selectedCategoryId = categorySelection.categoryId const selectedSubcategoryId = categorySelection.subcategoryId const dirty = useMemo( @@ -225,8 +269,48 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { const isAvulso = Boolean(((ticket.company ?? null) as { isAvulso?: boolean } | null)?.isAvulso ?? false) const [queueSelection, setQueueSelection] = useState(currentQueueName) const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, currentQueueName]) - const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId]) - const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty + const currentRequesterRecord = useMemo( + () => customers.find((customer) => customer.id === ticket.requester.id) ?? null, + [customers, ticket.requester.id] + ) + const currentCompanySelection = useMemo(() => { + if (currentRequesterRecord?.companyId) return currentRequesterRecord.companyId + if (ticket.company?.id) return String(ticket.company.id) + return NO_COMPANY_VALUE + }, [currentRequesterRecord, ticket.company?.id]) + const companyOptions = useMemo(() => { + const map = new Map() + companies.forEach((company) => { + map.set(company.id, { id: company.id, name: company.name, isAvulso: false }) + }) + customers.forEach((customer) => { + if (customer.companyId && !map.has(customer.companyId)) { + map.set(customer.companyId, { + id: customer.companyId, + name: customer.companyName ?? "Empresa sem nome", + isAvulso: customer.companyIsAvulso, + }) + } + }) + const includeNoCompany = customers.some((customer) => !customer.companyId) + const result: Array<{ id: string; name: string; isAvulso?: boolean }> = [ + { id: ALL_COMPANIES_VALUE, name: "Todas as empresas" }, + ] + 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] + }, [companies, customers]) + const filteredCustomers = useMemo(() => { + if (companySelection === ALL_COMPANIES_VALUE) return customers + if (companySelection === NO_COMPANY_VALUE) { + return customers.filter((customer) => !customer.companyId) + } + return customers.filter((customer) => customer.companyId === companySelection) + }, [companySelection, customers]) + const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id]) + const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty const companyLabel = useMemo(() => { if (ticket.company?.name) return ticket.company.name if (isAvulso) return "Cliente avulso" @@ -246,6 +330,42 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { const pauseDisabled = !canPauseWork const startDisabled = !canControlWork + useEffect(() => { + if (!customersInitialized) { + if (customers.length > 0) { + setRequesterSelection(ticket.requester.id) + setCompanySelection(currentCompanySelection) + setCustomersInitialized(true) + } + return + } + if (!editing) { + setRequesterSelection(ticket.requester.id) + setCompanySelection(currentCompanySelection) + } + }, [customersInitialized, customers, currentCompanySelection, ticket.requester.id, editing]) + + useEffect(() => { + if (!editing) return + const available = filteredCustomers + if (available.length === 0) { + if (requesterSelection !== null) { + setRequesterSelection(null) + } + return + } + if (!requesterSelection || !available.some((customer) => customer.id === requesterSelection)) { + setRequesterSelection(available[0].id) + } + }, [editing, filteredCustomers, requesterSelection]) + + useEffect(() => { + if (requesterSelection && requesterError) { + setRequesterError(null) + } + }, [requesterSelection, requesterError]) + + useEffect(() => { setStatus(ticket.status) }, [ticket.status]) @@ -312,6 +432,32 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { setQueueSelection(currentQueueName) } + if (requesterDirty && !isManager) { + if (!requesterSelection) { + setRequesterError("Selecione um solicitante.") + toast.error("Selecione um solicitante válido.", { id: "requester" }) + throw new Error("invalid-requester") + } + toast.loading("Atualizando solicitante...", { id: "requester" }) + try { + await changeRequester({ + ticketId: ticket.id as Id<"tickets">, + requesterId: requesterSelection as Id<"users">, + actorId: convexUserId as Id<"users">, + }) + toast.success("Solicitante atualizado!", { id: "requester" }) + } catch (requesterError) { + console.error(requesterError) + toast.error("Não foi possível atualizar o solicitante.", { id: "requester" }) + setRequesterSelection(ticket.requester.id) + setCompanySelection(currentCompanySelection) + throw requesterError + } + } else if (requesterDirty && isManager) { + setRequesterSelection(ticket.requester.id) + setCompanySelection(currentCompanySelection) + } + if (assigneeDirty && !isManager) { if (!assigneeSelection) { toast.error("Selecione um responsável válido.", { id: "assignee" }) @@ -402,6 +548,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { subcategoryId: currentSubcategoryId, }) setQueueSelection(currentQueueName) + setRequesterSelection(ticket.requester.id) + setCompanySelection(currentCompanySelection) + setRequesterError(null) setAssigneeSelection(currentAssigneeId) setAssigneeChangeReason("") setAssigneeReasonError(null) @@ -418,7 +567,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { }) setQueueSelection(ticket.queue ?? "") setAssigneeSelection(currentAssigneeId) - }, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue, currentAssigneeId]) + setRequesterSelection(ticket.requester.id) + setCompanySelection(currentCompanySelection) + }, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue, currentAssigneeId, ticket.requester.id, currentCompanySelection]) useEffect(() => { if (!editing) return @@ -1084,12 +1235,74 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { )}
- Solicitante - {ticket.requester.name} + Empresa + {editing && !isManager ? ( + + ) : ( + {companyLabel} + )}
- Empresa - {companyLabel} + Solicitante + {editing && !isManager ? ( +
+ + {filteredCustomers.length === 0 ? ( + Nenhum colaborador disponível para a empresa selecionada. + ) : null} + {requesterError ? {requesterError} : null} +
+ ) : ( + {ticket.requester.name} + )}
Responsável diff --git a/src/components/tickets/ticket-timeline.tsx b/src/components/tickets/ticket-timeline.tsx index 9fd22ee..354a28b 100644 --- a/src/components/tickets/ticket-timeline.tsx +++ b/src/components/tickets/ticket-timeline.tsx @@ -41,6 +41,7 @@ const timelineIcons: Record> = { PRIORITY_CHANGED: IconSquareCheck, ATTACHMENT_REMOVED: IconPaperclip, CATEGORY_CHANGED: IconFolders, + REQUESTER_CHANGED: IconUserCircle, MANAGER_NOTIFIED: IconUserCircle, VISIT_SCHEDULED: IconCalendar, CSAT_RECEIVED: IconStar, @@ -256,6 +257,19 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { if (entry.type === "QUEUE_CHANGED" && (payload.queueName || payload.queueId)) { message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "") } + if (entry.type === "REQUESTER_CHANGED") { + const name = payload.requesterName || payload.requesterEmail || payload.requesterId + const company = payload.companyName + if (name && company) { + message = `Solicitante alterado para ${name} (${company})` + } else if (name) { + message = `Solicitante alterado para ${name}` + } else if (company) { + message = `Solicitante associado à empresa ${company}` + } else { + message = "Solicitante alterado" + } + } if (entry.type === "PRIORITY_CHANGED" && (payload.toLabel || payload.to)) { message = "Prioridade alterada para " + (payload.toLabel || payload.to) } @@ -354,4 +368,4 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { ) -} +}