fix(filas): adiciona edição de fila após criação
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Queue>(`/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 {
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -25,13 +26,19 @@ export function FilasView({ queues }: { queues: Queue[] }) {
|
||||
fila, que distribui pros agentes segundo a estratégia escolhida.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setShowForm((s) => !s)}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setShowForm((s) => !s);
|
||||
}}
|
||||
>
|
||||
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||
{showForm ? "Cancelar" : "Nova fila"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && <NewQueueForm onDone={() => setShowForm(false)} />}
|
||||
{showForm && <QueueForm onDone={() => setShowForm(false)} />}
|
||||
|
||||
<Panel>
|
||||
<PanelHeader title="Filas cadastradas" description={`${queues.length} fila(s) neste tenant`} />
|
||||
@@ -54,7 +61,8 @@ export function FilasView({ queues }: { queues: Queue[] }) {
|
||||
</THead>
|
||||
<TBody>
|
||||
{queues.map((q) => (
|
||||
<TR key={q.id}>
|
||||
<Fragment key={q.id}>
|
||||
<TR>
|
||||
<TD>
|
||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||
<ListTree className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
@@ -70,9 +78,31 @@ export function FilasView({ queues }: { queues: Queue[] }) {
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{formatDate(q.createdAt)}</TD>
|
||||
<TD>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setEditingId((id) => (id === q.id ? null : q.id));
|
||||
}}
|
||||
aria-label={`Editar ${q.name}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
||||
</Button>
|
||||
<DeleteQueueButton queueId={q.id} queueName={q.name} />
|
||||
</div>
|
||||
</TD>
|
||||
</TR>
|
||||
{editingId === q.id && (
|
||||
<tr>
|
||||
<td colSpan={7} className="bg-muted/30 p-4">
|
||||
<QueueForm queue={q} onDone={() => setEditingId(null)} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
@@ -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<string>("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<string>(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<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
@@ -99,15 +130,16 @@ function NewQueueForm({ onDone }: { onDone: () => void }) {
|
||||
setError("Dê um nome à fila.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await createQueue({
|
||||
const input: CreateQueueInput = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
strategy,
|
||||
maxWaitTime: Number(maxWaitTime) || undefined,
|
||||
discardAbandonedAfter: Number(discardAbandonedAfter) || undefined,
|
||||
maxWaitTime: Number(maxWaitTime) || 0,
|
||||
discardAbandonedAfter: Number(discardAbandonedAfter) || 0,
|
||||
recordingEnabled,
|
||||
});
|
||||
};
|
||||
startTransition(async () => {
|
||||
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}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2">
|
||||
{isEditing && (
|
||||
<Button type="button" variant="ghost" onClick={onDone} disabled={pending}>
|
||||
Cancelar
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Criando…" : "Criar fila"}
|
||||
{pending ? "Salvando…" : isEditing ? "Salvar alterações" : "Criar fila"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user