feat: enable assignee selection when creating tickets
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
parent
fe7025d433
commit
be27dcfd15
4 changed files with 182 additions and 16 deletions
|
|
@ -5,6 +5,8 @@ import { Id, type Doc } from "./_generated/dataModel";
|
|||
|
||||
import { requireCustomer, requireStaff, requireUser } from "./rbac";
|
||||
|
||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
|
||||
|
||||
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
||||
"Suporte N1": "Chamados",
|
||||
"suporte-n1": "Chamados",
|
||||
|
|
@ -459,6 +461,7 @@ export const create = mutation({
|
|||
channel: v.string(),
|
||||
queueId: v.optional(v.id("queues")),
|
||||
requesterId: v.id("users"),
|
||||
assigneeId: v.optional(v.id("users")),
|
||||
categoryId: v.id("ticketCategories"),
|
||||
subcategoryId: v.id("ticketSubcategories"),
|
||||
customFields: v.optional(
|
||||
|
|
@ -471,11 +474,34 @@ export const create = mutation({
|
|||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { role } = await requireUser(ctx, args.actorId, args.tenantId)
|
||||
const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId)
|
||||
if (role === "CUSTOMER" && args.requesterId !== args.actorId) {
|
||||
throw new ConvexError("Clientes só podem abrir chamados para si mesmos")
|
||||
}
|
||||
|
||||
if (args.assigneeId && (!role || !STAFF_ROLES.has(role))) {
|
||||
throw new ConvexError("Somente a equipe interna pode definir o responsável")
|
||||
}
|
||||
|
||||
let initialAssigneeId: Id<"users"> | undefined
|
||||
let initialAssignee: Doc<"users"> | null = null
|
||||
|
||||
if (args.assigneeId) {
|
||||
const assignee = (await ctx.db.get(args.assigneeId)) as Doc<"users"> | null
|
||||
if (!assignee || assignee.tenantId !== args.tenantId) {
|
||||
throw new ConvexError("Responsável inválido")
|
||||
}
|
||||
const normalizedAssigneeRole = (assignee.role ?? "AGENT").toUpperCase()
|
||||
if (!STAFF_ROLES.has(normalizedAssigneeRole)) {
|
||||
throw new ConvexError("Responsável inválido")
|
||||
}
|
||||
initialAssigneeId = assignee._id
|
||||
initialAssignee = assignee
|
||||
} else if (role && STAFF_ROLES.has(role)) {
|
||||
initialAssigneeId = actorUser._id
|
||||
initialAssignee = actorUser
|
||||
}
|
||||
|
||||
const subject = args.subject.trim();
|
||||
if (subject.length < 3) {
|
||||
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
|
||||
|
|
@ -510,7 +536,7 @@ export const create = mutation({
|
|||
categoryId: args.categoryId,
|
||||
subcategoryId: args.subcategoryId,
|
||||
requesterId: args.requesterId,
|
||||
assigneeId: undefined,
|
||||
assigneeId: initialAssigneeId,
|
||||
working: false,
|
||||
activeSessionId: undefined,
|
||||
totalWorkedMs: 0,
|
||||
|
|
@ -531,6 +557,16 @@ export const create = mutation({
|
|||
payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl },
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
if (initialAssigneeId && initialAssignee) {
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: id,
|
||||
type: "ASSIGNEE_CHANGED",
|
||||
payload: { assigneeId: initialAssigneeId, assigneeName: initialAssignee.name, actorId: args.actorId },
|
||||
createdAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
});
|
||||
|
|
@ -558,11 +594,24 @@ export const addComment = mutation({
|
|||
throw new ConvexError("Ticket não encontrado")
|
||||
}
|
||||
|
||||
const author = (await ctx.db.get(args.authorId)) as Doc<"users"> | null
|
||||
if (!author || author.tenantId !== ticket.tenantId) {
|
||||
throw new ConvexError("Autor do comentário inválido")
|
||||
}
|
||||
|
||||
const normalizedRole = (author.role ?? "AGENT").toUpperCase()
|
||||
|
||||
if (ticket.requesterId === args.authorId) {
|
||||
if (normalizedRole === "CUSTOMER") {
|
||||
await requireCustomer(ctx, args.authorId, ticket.tenantId)
|
||||
if (args.visibility !== "PUBLIC") {
|
||||
throw new ConvexError("Clientes só podem registrar comentários públicos")
|
||||
}
|
||||
} else if (STAFF_ROLES.has(normalizedRole)) {
|
||||
await requireStaff(ctx, args.authorId, ticket.tenantId)
|
||||
} else {
|
||||
throw new ConvexError("Autor não possui permissão para comentar")
|
||||
}
|
||||
} else {
|
||||
await requireStaff(ctx, args.authorId, ticket.tenantId)
|
||||
}
|
||||
|
|
@ -577,11 +626,10 @@ export const addComment = mutation({
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const author = await ctx.db.get(args.authorId);
|
||||
await ctx.db.insert("ticketEvents", {
|
||||
ticketId: args.ticketId,
|
||||
type: "COMMENT_ADDED",
|
||||
payload: { authorId: args.authorId, authorName: author?.name, authorAvatar: author?.avatarUrl },
|
||||
payload: { authorId: args.authorId, authorName: author.name, authorAvatar: author.avatarUrl },
|
||||
createdAt: now,
|
||||
});
|
||||
// bump ticket updatedAt
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { requireAdmin } from "./rbac";
|
||||
|
||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]);
|
||||
|
||||
export const ensureUser = mutation({
|
||||
args: {
|
||||
|
|
@ -69,11 +72,41 @@ export const ensureUser = mutation({
|
|||
export const listAgents = query({
|
||||
args: { tenantId: v.string() },
|
||||
handler: async (ctx, { tenantId }) => {
|
||||
const agents = await ctx.db
|
||||
const users = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_tenant_role", (q) => q.eq("tenantId", tenantId).eq("role", "AGENT"))
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
return agents;
|
||||
|
||||
return users
|
||||
.filter((user) => {
|
||||
const normalizedRole = (user.role ?? "AGENT").toUpperCase();
|
||||
return STAFF_ROLES.has(normalizedRole);
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"));
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteUser = mutation({
|
||||
args: { userId: v.id("users"), actorId: v.id("users") },
|
||||
handler: async (ctx, { userId, actorId }) => {
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user) {
|
||||
return { status: "not_found" };
|
||||
}
|
||||
|
||||
await requireAdmin(ctx, actorId, user.tenantId);
|
||||
|
||||
const assignedTickets = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", user.tenantId).eq("assigneeId", userId))
|
||||
.take(1);
|
||||
|
||||
if (assignedTickets.length > 0) {
|
||||
throw new ConvexError("Usuário ainda está atribuído a tickets");
|
||||
}
|
||||
|
||||
await ctx.db.delete(userId);
|
||||
return { status: "deleted" };
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import type { Doc, Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
|
|
@ -40,12 +40,18 @@ export default function NewTicketPage() {
|
|||
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw])
|
||||
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 [subject, setSubject] = useState("")
|
||||
const [summary, setSummary] = useState("")
|
||||
const [priority, setPriority] = useState<TicketPriority>("MEDIUM")
|
||||
const [channel, setChannel] = useState("MANUAL")
|
||||
const [queueName, setQueueName] = useState<string | null>(null)
|
||||
const [assigneeId, setAssigneeId] = useState<string | null>(null)
|
||||
const [description, setDescription] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [subjectError, setSubjectError] = useState<string | null>(null)
|
||||
|
|
@ -53,8 +59,17 @@ 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 [assigneeInitialized, setAssigneeInitialized] = useState(false)
|
||||
|
||||
const queueOptions = useMemo(() => queues.map((q) => q.name), [queues])
|
||||
const assigneeSelectValue = assigneeId ?? "NONE"
|
||||
|
||||
useEffect(() => {
|
||||
if (assigneeInitialized) return
|
||||
if (!convexUserId) return
|
||||
setAssigneeId(convexUserId)
|
||||
setAssigneeInitialized(true)
|
||||
}, [assigneeInitialized, convexUserId])
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
|
@ -81,6 +96,7 @@ export default function NewTicketPage() {
|
|||
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 id = await create({
|
||||
actorId: convexUserId as Id<"users">,
|
||||
tenantId: DEFAULT_TENANT_ID,
|
||||
|
|
@ -90,6 +106,7 @@ export default function NewTicketPage() {
|
|||
channel,
|
||||
queueId,
|
||||
requesterId: convexUserId as Id<"users">,
|
||||
assigneeId: assigneeToSend,
|
||||
categoryId: categoryId as Id<"ticketCategories">,
|
||||
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
|
||||
})
|
||||
|
|
@ -178,7 +195,7 @@ export default function NewTicketPage() {
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium text-neutral-700">Prioridade</span>
|
||||
<Select value={priority} onValueChange={(value) => setPriority(value as TicketPriority)}>
|
||||
|
|
@ -248,6 +265,24 @@ export default function NewTicketPage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium text-neutral-700">Responsável</span>
|
||||
<Select value={assigneeSelectValue} onValueChange={(value) => setAssigneeId(value === "NONE" ? null : value)}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client"
|
||||
|
||||
import { z } from "zod"
|
||||
import { useMemo, useState } from "react"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type { Doc, Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
// @ts-expect-error Convex runtime API lacks TypeScript definitions
|
||||
|
|
@ -33,6 +33,7 @@ const schema = z.object({
|
|||
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(),
|
||||
assigneeId: z.string().nullable().optional(),
|
||||
categoryId: z.string().min(1, "Selecione uma categoria"),
|
||||
subcategoryId: z.string().min(1, "Selecione uma categoria secundária"),
|
||||
})
|
||||
|
|
@ -49,6 +50,7 @@ export function NewTicketDialog() {
|
|||
priority: "MEDIUM",
|
||||
channel: "MANUAL",
|
||||
queueName: null,
|
||||
assigneeId: null,
|
||||
categoryId: "",
|
||||
subcategoryId: "",
|
||||
},
|
||||
|
|
@ -65,15 +67,34 @@ export function NewTicketDialog() {
|
|||
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw])
|
||||
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 [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
|
||||
const priorityValue = form.watch("priority") as TicketPriority
|
||||
const channelValue = form.watch("channel")
|
||||
const queueValue = form.watch("queueName") ?? "NONE"
|
||||
const assigneeValue = form.watch("assigneeId") ?? null
|
||||
const assigneeSelectValue = assigneeValue ?? "NONE"
|
||||
const categoryIdValue = form.watch("categoryId")
|
||||
const subcategoryIdValue = form.watch("subcategoryId")
|
||||
const isSubmitted = form.formState.isSubmitted
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setAssigneeInitialized(false)
|
||||
return
|
||||
}
|
||||
if (assigneeInitialized) return
|
||||
if (!convexUserId) return
|
||||
form.setValue("assigneeId", convexUserId, { shouldDirty: false, shouldTouch: false })
|
||||
setAssigneeInitialized(true)
|
||||
}, [open, assigneeInitialized, convexUserId, form])
|
||||
|
||||
const handleCategoryChange = (value: string) => {
|
||||
const previous = form.getValues("categoryId") ?? ""
|
||||
|
|
@ -112,6 +133,7 @@ export function NewTicketDialog() {
|
|||
toast.loading("Criando ticket…", { id: "new-ticket" })
|
||||
try {
|
||||
const sel = queues.find((q) => q.name === values.queueName)
|
||||
const selectedAssignee = form.getValues("assigneeId") ?? null
|
||||
const id = await create({
|
||||
actorId: convexUserId as Id<"users">,
|
||||
tenantId: DEFAULT_TENANT_ID,
|
||||
|
|
@ -121,6 +143,7 @@ export function NewTicketDialog() {
|
|||
channel: values.channel,
|
||||
queueId: sel?.id as Id<"queues"> | undefined,
|
||||
requesterId: convexUserId as Id<"users">,
|
||||
assigneeId: selectedAssignee ? (selectedAssignee as Id<"users">) : undefined,
|
||||
categoryId: values.categoryId as Id<"ticketCategories">,
|
||||
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
|
||||
})
|
||||
|
|
@ -138,6 +161,7 @@ export function NewTicketDialog() {
|
|||
toast.success("Ticket criado!", { id: "new-ticket" })
|
||||
setOpen(false)
|
||||
form.reset()
|
||||
setAssigneeInitialized(false)
|
||||
setAttachments([])
|
||||
// Navegar para o ticket recém-criado
|
||||
window.location.href = `/tickets/${id}`
|
||||
|
|
@ -305,6 +329,32 @@ export function NewTicketDialog() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<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>
|
||||
</FieldGroup>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue