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()
|
@IsBoolean()
|
||||||
recordingEnabled?: boolean;
|
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,
|
HttpStatus,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
@@ -18,7 +19,7 @@ import { PermissionGuard } from "../common/guards/permission.guard";
|
|||||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import { getRedisClient } from "../common/redis";
|
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";
|
const QUEUES_SYNC_CHANNEL = "b2bcall:queues:sync";
|
||||||
|
|
||||||
@@ -101,6 +102,58 @@ export class QueuesController {
|
|||||||
return queue;
|
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")
|
@RequirePermission("queues.manage")
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@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 }> {
|
export async function deleteQueue(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { Fragment, useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { Panel, PanelHeader } from "@/components/ui/panel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
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 { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import { QUEUE_STRATEGIES, QUEUE_STRATEGY_LABELS, type Queue } from "@/lib/callcenter-types";
|
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[] }) {
|
export function FilasView({ queues }: { queues: Queue[] }) {
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<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.
|
fila, que distribui pros agentes segundo a estratégia escolhida.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||||
{showForm ? "Cancelar" : "Nova fila"}
|
{showForm ? "Cancelar" : "Nova fila"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && <NewQueueForm onDone={() => setShowForm(false)} />}
|
{showForm && <QueueForm onDone={() => setShowForm(false)} />}
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader title="Filas cadastradas" description={`${queues.length} fila(s) neste tenant`} />
|
<PanelHeader title="Filas cadastradas" description={`${queues.length} fila(s) neste tenant`} />
|
||||||
@@ -54,7 +61,8 @@ export function FilasView({ queues }: { queues: Queue[] }) {
|
|||||||
</THead>
|
</THead>
|
||||||
<TBody>
|
<TBody>
|
||||||
{queues.map((q) => (
|
{queues.map((q) => (
|
||||||
<TR key={q.id}>
|
<Fragment key={q.id}>
|
||||||
|
<TR>
|
||||||
<TD>
|
<TD>
|
||||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||||
<ListTree className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
<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>
|
||||||
<TD className="text-muted-foreground">{formatDate(q.createdAt)}</TD>
|
<TD className="text-muted-foreground">{formatDate(q.createdAt)}</TD>
|
||||||
<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} />
|
<DeleteQueueButton queueId={q.id} queueName={q.name} />
|
||||||
|
</div>
|
||||||
</TD>
|
</TD>
|
||||||
</TR>
|
</TR>
|
||||||
|
{editingId === q.id && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="bg-muted/30 p-4">
|
||||||
|
<QueueForm queue={q} onDone={() => setEditingId(null)} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</TBody>
|
</TBody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -82,13 +112,14 @@ export function FilasView({ queues }: { queues: Queue[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewQueueForm({ onDone }: { onDone: () => void }) {
|
function QueueForm({ queue, onDone }: { queue?: Queue; onDone: () => void }) {
|
||||||
const [name, setName] = useState("");
|
const isEditing = !!queue;
|
||||||
const [description, setDescription] = useState("");
|
const [name, setName] = useState(queue?.name ?? "");
|
||||||
const [strategy, setStrategy] = useState<string>("LONGEST_IDLE_AGENT");
|
const [description, setDescription] = useState(queue?.description ?? "");
|
||||||
const [maxWaitTime, setMaxWaitTime] = useState("120");
|
const [strategy, setStrategy] = useState<string>(queue?.strategy ?? "LONGEST_IDLE_AGENT");
|
||||||
const [discardAbandonedAfter, setDiscardAbandonedAfter] = useState("60");
|
const [maxWaitTime, setMaxWaitTime] = useState(String(queue?.maxWaitTime ?? 120));
|
||||||
const [recordingEnabled, setRecordingEnabled] = useState(false);
|
const [discardAbandonedAfter, setDiscardAbandonedAfter] = useState(String(queue?.discardAbandonedAfter ?? 60));
|
||||||
|
const [recordingEnabled, setRecordingEnabled] = useState(queue?.recordingEnabled ?? false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -99,15 +130,16 @@ function NewQueueForm({ onDone }: { onDone: () => void }) {
|
|||||||
setError("Dê um nome à fila.");
|
setError("Dê um nome à fila.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startTransition(async () => {
|
const input: CreateQueueInput = {
|
||||||
const result = await createQueue({
|
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
strategy,
|
strategy,
|
||||||
maxWaitTime: Number(maxWaitTime) || undefined,
|
maxWaitTime: Number(maxWaitTime) || 0,
|
||||||
discardAbandonedAfter: Number(discardAbandonedAfter) || undefined,
|
discardAbandonedAfter: Number(discardAbandonedAfter) || 0,
|
||||||
recordingEnabled,
|
recordingEnabled,
|
||||||
});
|
};
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = isEditing ? await updateQueue(queue.id, input) : await createQueue(input);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
return;
|
return;
|
||||||
@@ -164,9 +196,14 @@ function NewQueueForm({ onDone }: { onDone: () => void }) {
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</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}>
|
<Button type="submit" disabled={pending}>
|
||||||
{pending ? "Criando…" : "Criar fila"}
|
{pending ? "Salvando…" : isEditing ? "Salvar alterações" : "Criar fila"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user