diff --git a/convex/tickets.ts b/convex/tickets.ts
index 7dc6708..a604b77 100644
--- a/convex/tickets.ts
+++ b/convex/tickets.ts
@@ -973,6 +973,7 @@ export const changeAssignee = mutation({
}
const ticketDoc = ticket as Doc<"tickets">
const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
+ const isAdmin = viewer.role === "ADMIN"
const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null
if (!assignee || assignee.tenantId !== ticketDoc.tenantId) {
throw new ConvexError("Responsável inválido")
@@ -980,6 +981,11 @@ export const changeAssignee = mutation({
if (viewer.role === "MANAGER") {
throw new ConvexError("Gestores não podem reatribuir chamados")
}
+ const currentAssigneeId = ticketDoc.assigneeId ?? null
+ if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
+ throw new ConvexError("Somente o responsável atual pode reatribuir este chamado")
+ }
+
const now = Date.now();
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
await ctx.db.insert("ticketEvents", {
@@ -1165,12 +1171,28 @@ export const startWork = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
- await requireStaff(ctx, actorId, ticket.tenantId)
- if (ticket.activeSessionId) {
- return { status: "already_started", sessionId: ticket.activeSessionId }
+ const ticketDoc = ticket as Doc<"tickets">
+ const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
+ const isAdmin = viewer.role === "ADMIN"
+ const currentAssigneeId = ticketDoc.assigneeId ?? null
+
+ if (currentAssigneeId && currentAssigneeId !== actorId && !isAdmin) {
+ throw new ConvexError("Somente o responsável atual pode iniciar este chamado")
+ }
+
+ if (ticketDoc.activeSessionId) {
+ return { status: "already_started", sessionId: ticketDoc.activeSessionId }
}
const now = Date.now()
+ let assigneePatched = false
+
+ if (!currentAssigneeId) {
+ await ctx.db.patch(ticketId, { assigneeId: actorId, updatedAt: now })
+ ticketDoc.assigneeId = actorId
+ assigneePatched = true
+ }
+
const sessionId = await ctx.db.insert("ticketWorkSessions", {
ticketId,
agentId: actorId,
@@ -1184,11 +1206,25 @@ export const startWork = mutation({
updatedAt: now,
})
- const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
+ if (assigneePatched) {
+ await ctx.db.insert("ticketEvents", {
+ ticketId,
+ type: "ASSIGNEE_CHANGED",
+ payload: { assigneeId: actorId, assigneeName: viewer.user.name, actorId },
+ createdAt: now,
+ })
+ }
+
await ctx.db.insert("ticketEvents", {
ticketId,
type: "WORK_STARTED",
- payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, sessionId, workType: (workType ?? "INTERNAL").toUpperCase() },
+ payload: {
+ actorId,
+ actorName: viewer.user.name,
+ actorAvatar: viewer.user.avatarUrl,
+ sessionId,
+ workType: (workType ?? "INTERNAL").toUpperCase(),
+ },
createdAt: now,
})
@@ -1208,8 +1244,14 @@ export const pauseWork = mutation({
if (!ticket) {
throw new ConvexError("Ticket não encontrado")
}
- await requireStaff(ctx, actorId, ticket.tenantId)
- if (!ticket.activeSessionId) {
+ const ticketDoc = ticket as Doc<"tickets">
+ const viewer = await requireTicketStaff(ctx, actorId, ticketDoc)
+ const isAdmin = viewer.role === "ADMIN"
+ if (ticketDoc.assigneeId && ticketDoc.assigneeId !== actorId && !isAdmin) {
+ throw new ConvexError("Somente o responsável atual pode pausar este chamado")
+ }
+
+ if (!ticketDoc.activeSessionId) {
return { status: "already_paused" }
}
@@ -1217,7 +1259,7 @@ export const pauseWork = mutation({
throw new ConvexError("Motivo de pausa inválido")
}
- const session = await ctx.db.get(ticket.activeSessionId)
+ const session = await ctx.db.get(ticketDoc.activeSessionId)
if (!session) {
await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false })
return { status: "session_missing" }
@@ -1226,7 +1268,7 @@ export const pauseWork = mutation({
const now = Date.now()
const durationMs = now - session.startedAt
- await ctx.db.patch(ticket.activeSessionId, {
+ await ctx.db.patch(ticketDoc.activeSessionId, {
stoppedAt: now,
durationMs,
pauseReason: reason,
diff --git a/src/app/portal/profile/page.tsx b/src/app/portal/profile/page.tsx
index 9c613ec..ecc10f2 100644
--- a/src/app/portal/profile/page.tsx
+++ b/src/app/portal/profile/page.tsx
@@ -12,7 +12,9 @@ export const metadata: Metadata = {
export default async function PortalProfilePage() {
const session = await requireAuthenticatedSession()
const role = (session.user.role ?? "").toLowerCase()
- if (role !== "collaborator" && role !== "manager") {
+ const persona = (session.user.machinePersona ?? "").toLowerCase()
+ const allowed = role === "collaborator" || role === "manager" || persona === "collaborator" || persona === "manager"
+ if (!allowed) {
redirect("/portal")
}
diff --git a/src/components/admin/machines/admin-machines-overview.tsx b/src/components/admin/machines/admin-machines-overview.tsx
index 40d5d40..0f097e2 100644
--- a/src/components/admin/machines/admin-machines-overview.tsx
+++ b/src/components/admin/machines/admin-machines-overview.tsx
@@ -1962,6 +1962,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineId: machine.id }),
+ credentials: "include",
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
toast.success("Máquina excluída")
diff --git a/src/components/tickets/status-badge.tsx b/src/components/tickets/status-badge.tsx
index e565967..254f626 100644
--- a/src/components/tickets/status-badge.tsx
+++ b/src/components/tickets/status-badge.tsx
@@ -17,7 +17,7 @@ export function TicketStatusBadge({ status, className }: TicketStatusBadgeProps)
const parsed = ticketStatusSchema.parse(status)
const styles = statusStyles[parsed]
return (
-
+
{styles?.label ?? parsed}
)
diff --git a/src/components/tickets/ticket-comments.rich.tsx b/src/components/tickets/ticket-comments.rich.tsx
index e7997ea..b725175 100644
--- a/src/components/tickets/ticket-comments.rich.tsx
+++ b/src/components/tickets/ticket-comments.rich.tsx
@@ -17,7 +17,7 @@ import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
-import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
@@ -505,15 +505,18 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
diff --git a/src/components/tickets/ticket-summary-header.tsx b/src/components/tickets/ticket-summary-header.tsx
index 5556fbb..f774bd9 100644
--- a/src/components/tickets/ticket-summary-header.tsx
+++ b/src/components/tickets/ticket-summary-header.tsx
@@ -89,7 +89,9 @@ function formatDuration(durationMs: number) {
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const { convexUserId, role, isStaff } = useAuth()
- const isManager = role === "manager"
+ const normalizedRole = (role ?? "").toLowerCase()
+ const isManager = normalizedRole === "manager"
+ const isAdmin = normalizedRole === "admin"
useDefaultQueues(ticket.tenantId)
const changeAssignee = useMutation(api.tickets.changeAssignee)
const changeQueue = useMutation(api.tickets.changeQueue)
@@ -138,6 +140,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
subcategoryId: ticket.subcategory?.id ?? "",
}
)
+ const currentAssigneeId = ticket.assignee?.id ?? ""
+ const [assigneeSelection, setAssigneeSelection] = useState(currentAssigneeId)
const [saving, setSaving] = useState(false)
const [pauseDialogOpen, setPauseDialogOpen] = useState(false)
const [pauseReason, setPauseReason] = useState(PAUSE_REASONS[0]?.value ?? "NO_CONTACT")
@@ -159,13 +163,20 @@ 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 formDirty = dirty || categoryDirty || queueDirty
+ const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
+ const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty
const activeCategory = useMemo(
() => categories.find((category) => category.id === selectedCategoryId) ?? null,
[categories, selectedCategoryId]
)
const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory])
+ const hasAssignee = Boolean(currentAssigneeId)
+ const isCurrentResponsible = hasAssignee && convexUserId ? currentAssigneeId === convexUserId : false
+ const canControlWork = isAdmin || !hasAssignee || isCurrentResponsible
+ const canPauseWork = isAdmin || isCurrentResponsible
+ const pauseDisabled = !canPauseWork
+ const startDisabled = !canControlWork
async function handleSave() {
if (!convexUserId || !formDirty) {
@@ -225,6 +236,31 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setQueueSelection(currentQueueName)
}
+ if (assigneeDirty && !isManager) {
+ if (!assigneeSelection) {
+ toast.error("Selecione um responsável válido.", { id: "assignee" })
+ setAssigneeSelection(currentAssigneeId)
+ throw new Error("invalid-assignee")
+ } else {
+ toast.loading("Atualizando responsável...", { id: "assignee" })
+ try {
+ await changeAssignee({
+ ticketId: ticket.id as Id<"tickets">,
+ assigneeId: assigneeSelection as Id<"users">,
+ actorId: convexUserId as Id<"users">,
+ })
+ toast.success("Responsável atualizado!", { id: "assignee" })
+ } catch (error) {
+ console.error(error)
+ toast.error("Não foi possível atualizar o responsável.", { id: "assignee" })
+ setAssigneeSelection(currentAssigneeId)
+ throw error
+ }
+ }
+ } else if (assigneeDirty && isManager) {
+ setAssigneeSelection(currentAssigneeId)
+ }
+
if (dirty) {
toast.loading("Salvando alterações...", { id: "save-header" })
if (subject !== ticket.subject) {
@@ -259,6 +295,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
subcategoryId: currentSubcategoryId,
})
setQueueSelection(currentQueueName)
+ setAssigneeSelection(currentAssigneeId)
setEditing(false)
}
@@ -269,6 +306,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
subcategoryId: ticket.subcategory?.id ?? "",
})
setQueueSelection(ticket.queue ?? "")
+ setAssigneeSelection(ticket.assignee?.id ?? "")
}, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue])
useEffect(() => {
@@ -387,8 +425,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
} else {
toast.success("Atendimento iniciado", { id: "work" })
}
- } catch {
- toast.error("Não foi possível atualizar o atendimento", { id: "work" })
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Não foi possível atualizar o atendimento"
+ toast.error(message, { id: "work" })
}
}
@@ -410,8 +449,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
toast.success("Atendimento pausado", { id: "work" })
}
setPauseDialogOpen(false)
- } catch {
- toast.error("Não foi possível atualizar o atendimento", { id: "work" })
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Não foi possível atualizar o atendimento"
+ toast.error(message, { id: "work" })
} finally {
setPausing(false)
}
@@ -506,16 +546,41 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
/>
{isPlaying ? (
-
+
+
+
+
+
+
+ {pauseDisabled ? (
+
+ Apenas o responsável atual ou um administrador pode pausar o atendimento.
+
+ ) : null}
+
+ ) : startDisabled ? (
+
+
+
+
+
+
+
+ Apenas o responsável atual ou um administrador pode iniciar este atendimento.
+
+
) : (
@@ -666,17 +731,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
{editing ? (