From 26cc79cec2cef567ffa2ce9c14769dfc88ad2fc2 Mon Sep 17 00:00:00 2001 From: Matheus Date: Sun, 30 Aug 2026 20:13:03 -0300 Subject: [PATCH] =?UTF-8?q?fix(filas):=20adiciona=20edi=C3=A7=C3=A3o=20de?= =?UTF-8?q?=20fila=20ap=C3=B3s=20cria=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Achado real reportado pelo usuário: "na fila nao tem opcao de editar a fila apos a criacao". queues.controller.ts só tinha create/list/delete — nunca existiu PATCH. UpdateQueueDto (mesmos campos do create, todos opcionais) + PATCH /queues/:id. Tela ganhou um botão "Editar" por linha, abrindo um formulário inline pré-preenchido (reaproveita o mesmo componente do "Nova fila", só muda entre createQueue/updateQueue). Testado ponta a ponta com Playwright de verdade (não só a API): abrir o form de edição confirmando que veio pré-preenchido com os valores reais da fila, editar nome e espera máxima, salvar, e confirmar que a tabela atualizou com os novos valores. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8 --- apps/api/src/queues/dto/create-queue.dto.ts | 77 ++++++++++++ apps/api/src/queues/queues.controller.ts | 55 +++++++- .../src/app/app/callcenter/filas/actions.ts | 14 +++ .../app/app/callcenter/filas/filas-view.tsx | 119 ++++++++++++------ 4 files changed, 223 insertions(+), 42 deletions(-) diff --git a/apps/api/src/queues/dto/create-queue.dto.ts b/apps/api/src/queues/dto/create-queue.dto.ts index d078ba0..4df2bc1 100644 --- a/apps/api/src/queues/dto/create-queue.dto.ts +++ b/apps/api/src/queues/dto/create-queue.dto.ts @@ -86,3 +86,80 @@ export class CreateQueueDto { @IsBoolean() recordingEnabled?: boolean; } + +export class UpdateQueueDto { + @IsOptional() + @IsString() + @MaxLength(80) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + description?: string; + + @IsOptional() + @IsIn(STRATEGIES) + strategy?: (typeof STRATEGIES)[number]; + + @IsOptional() + @IsString() + @MaxLength(255) + mohSound?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + announceSound?: string; + + @IsOptional() + @IsInt() + @Min(0) + @Max(3600) + announceFrequency?: number; + + @IsOptional() + @IsInt() + @Min(0) + @Max(3600) + maxWaitTime?: number; + + @IsOptional() + @IsInt() + @Min(0) + @Max(3600) + maxWaitTimeWithNoAgent?: number; + + @IsOptional() + @IsString() + @MaxLength(80) + agentNoAnswerStatus?: string; + + @IsOptional() + @IsBoolean() + tierRulesApply?: boolean; + + @IsOptional() + @IsInt() + @Min(1) + @Max(3600) + tierRuleWaitSecond?: number; + + @IsOptional() + @IsInt() + @Min(0) + @Max(3600) + discardAbandonedAfter?: number; + + @IsOptional() + @IsBoolean() + abandonedResumeAllowed?: boolean; + + @IsOptional() + @IsBoolean() + skipAgentsWithExternalCalls?: boolean; + + @IsOptional() + @IsBoolean() + recordingEnabled?: boolean; +} diff --git a/apps/api/src/queues/queues.controller.ts b/apps/api/src/queues/queues.controller.ts index 62551f9..57adc16 100644 --- a/apps/api/src/queues/queues.controller.ts +++ b/apps/api/src/queues/queues.controller.ts @@ -7,6 +7,7 @@ import { HttpStatus, NotFoundException, Param, + Patch, Post, UseGuards, } from "@nestjs/common"; @@ -18,7 +19,7 @@ import { PermissionGuard } from "../common/guards/permission.guard"; import { RequirePermission } from "../common/decorators/require-permission.decorator"; import { CurrentUser } from "../common/decorators/current-user.decorator"; import { getRedisClient } from "../common/redis"; -import { CreateQueueDto } from "./dto/create-queue.dto"; +import { CreateQueueDto, UpdateQueueDto } from "./dto/create-queue.dto"; const QUEUES_SYNC_CHANNEL = "b2bcall:queues:sync"; @@ -101,6 +102,58 @@ export class QueuesController { return queue; } + /** + * Achado real reportado pelo usuário: "na fila nao tem opcao de editar + * a fila apos a criacao" — só existia create/delete até aqui. + */ + @RequirePermission("queues.manage") + @Patch(":id") + async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateQueueDto) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const result = await withTenantContext(prisma, tenantId, (tx) => + tx.queue.updateMany({ + where: { id, tenantId, deletedAt: null }, + data: { + ...(dto.name !== undefined ? { name: dto.name } : {}), + ...(dto.description !== undefined ? { description: dto.description } : {}), + ...(dto.strategy !== undefined ? { strategy: dto.strategy } : {}), + ...(dto.mohSound !== undefined ? { mohSound: dto.mohSound } : {}), + ...(dto.announceSound !== undefined ? { announceSound: dto.announceSound } : {}), + ...(dto.announceFrequency !== undefined ? { announceFrequency: dto.announceFrequency } : {}), + ...(dto.maxWaitTime !== undefined ? { maxWaitTime: dto.maxWaitTime } : {}), + ...(dto.maxWaitTimeWithNoAgent !== undefined ? { maxWaitTimeWithNoAgent: dto.maxWaitTimeWithNoAgent } : {}), + ...(dto.agentNoAnswerStatus !== undefined ? { agentNoAnswerStatus: dto.agentNoAnswerStatus } : {}), + ...(dto.tierRulesApply !== undefined ? { tierRulesApply: dto.tierRulesApply } : {}), + ...(dto.tierRuleWaitSecond !== undefined ? { tierRuleWaitSecond: dto.tierRuleWaitSecond } : {}), + ...(dto.discardAbandonedAfter !== undefined ? { discardAbandonedAfter: dto.discardAbandonedAfter } : {}), + ...(dto.abandonedResumeAllowed !== undefined ? { abandonedResumeAllowed: dto.abandonedResumeAllowed } : {}), + ...(dto.skipAgentsWithExternalCalls !== undefined ? { skipAgentsWithExternalCalls: dto.skipAgentsWithExternalCalls } : {}), + ...(dto.recordingEnabled !== undefined ? { recordingEnabled: dto.recordingEnabled } : {}), + }, + }), + ); + if (result.count === 0) { + throw new NotFoundException(); + } + + const updated = await withTenantContext(prisma, tenantId, (tx) => tx.queue.findFirstOrThrow({ where: { id } })); + + await recordAuditEvent(prisma, { + action: "QUEUE_UPDATE", + tenantId, + userId: user.sub, + entityType: "queue", + entityId: id, + after: { ...dto }, + }); + + await notifyQueuesChanged(); + + return updated; + } + @RequirePermission("queues.manage") @Delete(":id") @HttpCode(HttpStatus.NO_CONTENT) diff --git a/apps/frontend/src/app/app/callcenter/filas/actions.ts b/apps/frontend/src/app/app/callcenter/filas/actions.ts index a5698b5..71811f4 100644 --- a/apps/frontend/src/app/app/callcenter/filas/actions.ts +++ b/apps/frontend/src/app/app/callcenter/filas/actions.ts @@ -40,6 +40,20 @@ export async function createQueue(input: CreateQueueInput): Promise<{ ok: true; } } +export async function updateQueue( + id: string, + input: CreateQueueInput, +): Promise<{ ok: true; queue: Queue } | { ok: false; error: string }> { + const session = await requireSession(); + try { + const queue = await apiFetch(`/queues/${id}`, session.accessToken, { method: "PATCH", body: JSON.stringify(input) }); + revalidatePath("/app/callcenter/filas"); + return { ok: true, queue }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + export async function deleteQueue(id: string): Promise<{ ok: true } | { ok: false; error: string }> { const session = await requireSession(); try { diff --git a/apps/frontend/src/app/app/callcenter/filas/filas-view.tsx b/apps/frontend/src/app/app/callcenter/filas/filas-view.tsx index c6a94ca..cc20083 100644 --- a/apps/frontend/src/app/app/callcenter/filas/filas-view.tsx +++ b/apps/frontend/src/app/app/callcenter/filas/filas-view.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState, useTransition } from "react"; +import { Fragment, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { ListTree, Plus, Trash2, X } from "lucide-react"; +import { ListTree, Pencil, Plus, Trash2, X } from "lucide-react"; import { Panel, PanelHeader } from "@/components/ui/panel"; import { Button } from "@/components/ui/button"; import { Input, Select, FieldLabel } from "@/components/ui/input"; @@ -10,10 +10,11 @@ import { Pill } from "@/components/ui/pill"; import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; import { formatDate } from "@/lib/format"; import { QUEUE_STRATEGIES, QUEUE_STRATEGY_LABELS, type Queue } from "@/lib/callcenter-types"; -import { createQueue, deleteQueue } from "./actions"; +import { createQueue, deleteQueue, updateQueue, type CreateQueueInput } from "./actions"; export function FilasView({ queues }: { queues: Queue[] }) { const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); return (
@@ -25,13 +26,19 @@ export function FilasView({ queues }: { queues: Queue[] }) { fila, que distribui pros agentes segundo a estratégia escolhida.

- - {showForm && setShowForm(false)} />} + {showForm && setShowForm(false)} />} @@ -54,25 +61,48 @@ export function FilasView({ queues }: { queues: Queue[] }) { {queues.map((q) => ( - - - - - {q.name} - - {q.description && {q.description}} - - {QUEUE_STRATEGY_LABELS[q.strategy] ?? q.strategy} - {q.maxWaitTime > 0 ? `${q.maxWaitTime}s` : "sem limite"} - {q.discardAbandonedAfter}s - - {q.recordingEnabled ? "Habilitada" : "Desabilitada"} - - {formatDate(q.createdAt)} - - - - + + + + + + {q.name} + + {q.description && {q.description}} + + {QUEUE_STRATEGY_LABELS[q.strategy] ?? q.strategy} + {q.maxWaitTime > 0 ? `${q.maxWaitTime}s` : "sem limite"} + {q.discardAbandonedAfter}s + + {q.recordingEnabled ? "Habilitada" : "Desabilitada"} + + {formatDate(q.createdAt)} + +
+ + +
+ + + {editingId === q.id && ( + + + setEditingId(null)} /> + + + )} +
))} @@ -82,13 +112,14 @@ export function FilasView({ queues }: { queues: Queue[] }) { ); } -function NewQueueForm({ onDone }: { onDone: () => void }) { - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [strategy, setStrategy] = useState("LONGEST_IDLE_AGENT"); - const [maxWaitTime, setMaxWaitTime] = useState("120"); - const [discardAbandonedAfter, setDiscardAbandonedAfter] = useState("60"); - const [recordingEnabled, setRecordingEnabled] = useState(false); +function QueueForm({ queue, onDone }: { queue?: Queue; onDone: () => void }) { + const isEditing = !!queue; + const [name, setName] = useState(queue?.name ?? ""); + const [description, setDescription] = useState(queue?.description ?? ""); + const [strategy, setStrategy] = useState(queue?.strategy ?? "LONGEST_IDLE_AGENT"); + const [maxWaitTime, setMaxWaitTime] = useState(String(queue?.maxWaitTime ?? 120)); + const [discardAbandonedAfter, setDiscardAbandonedAfter] = useState(String(queue?.discardAbandonedAfter ?? 60)); + const [recordingEnabled, setRecordingEnabled] = useState(queue?.recordingEnabled ?? false); const [error, setError] = useState(null); const [pending, startTransition] = useTransition(); @@ -99,15 +130,16 @@ function NewQueueForm({ onDone }: { onDone: () => void }) { setError("Dê um nome à fila."); return; } + const input: CreateQueueInput = { + name: name.trim(), + description: description.trim() || undefined, + strategy, + maxWaitTime: Number(maxWaitTime) || 0, + discardAbandonedAfter: Number(discardAbandonedAfter) || 0, + recordingEnabled, + }; startTransition(async () => { - const result = await createQueue({ - name: name.trim(), - description: description.trim() || undefined, - strategy, - maxWaitTime: Number(maxWaitTime) || undefined, - discardAbandonedAfter: Number(discardAbandonedAfter) || undefined, - recordingEnabled, - }); + const result = isEditing ? await updateQueue(queue.id, input) : await createQueue(input); if (!result.ok) { setError(result.error); return; @@ -164,9 +196,14 @@ function NewQueueForm({ onDone }: { onDone: () => void }) { {error}

)} -
+
+ {isEditing && ( + + )}