diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 4a3b00c..7922c22 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -607,10 +607,14 @@ function App() { )} {validatedCompany ? ( -
-
{validatedCompany.name}
-
Tenant: {validatedCompany.tenantId}
-
Slug: {validatedCompany.slug}
+
+
) : null}
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) { !open && setPreview(null)}> - - - Visualização de anexo + + + Visualização do anexo + + + {preview ? ( - <> +
{/* eslint-disable-next-line @next/next/no-img-element */} - Preview - + Preview do anexo +
) : null}
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 ? (