feat: add frontend, nginx reverse proxy and monitoring/reports extras
- apps/frontend: Next.js 15 (App Router) + Tailwind v4 + componentes estilo shadcn/ui sobre Radix UI + TanStack Query. Tema light/dark, logo processada. Menu completo (secao 52) com gating por permissao real. Todas as telas do checklist de aceite (secao 90) conectadas a endpoints reais (nao mockup): login, usuarios, perfis/permissoes, ramais/troncos, dialplan, filas/agentes, console do agente, campanhas (CPS/CSV/ iniciar/pausar), monitoramento ao vivo (polling, nao WebSocket real), TME/TMA, busca/export de chamadas, administracao do Asterisk, auditoria - infrastructure/nginx: reverse proxy colocando frontend+API na mesma origem (porta 80), antecipado da Fase 9 pois a API nao publica porta propria - apps/api: GET /api/monitoring/agents (estado corrente real via agent_state_events em aberto) e filtro queueId em GET /api/reports/calls Pendencia registrada: tela de Callbacks nao implementada (schema existe desde a Fase 6, mas nunca houve controller/service — construir a tela sem API real seria mockup). Verificacao visual em navegador nao foi possivel neste ambiente headless; validado via tsc/eslint/next build limpos + curl reproduzindo as chamadas do navegador (middleware de auth, 24 paginas protegidas via Nginx, endpoints de dados com cookie de sessao).
This commit is contained in:
50
TODO.md
50
TODO.md
@@ -273,10 +273,52 @@ relatórios/dashboard/compliance retornando dados reais e coerentes com o
|
|||||||
banco. Todos os fixtures de teste foram removidos/desativados ao final.
|
banco. Todos os fixtures de teste foram removidos/desativados ao final.
|
||||||
|
|
||||||
## Fase 8 — Frontend completo
|
## Fase 8 — Frontend completo
|
||||||
- [ ] Bootstrap Next.js + Tailwind + shadcn/ui + TanStack Query + WS client
|
- [x] Bootstrap Next.js 15 (App Router) + Tailwind v4 + componentes estilo
|
||||||
- [ ] Logo processada (b2bcall.png) + tema light/dark
|
shadcn/ui (escritos à mão sobre Radix UI, sem CLI interativo) +
|
||||||
- [ ] Menu completo (seção 52)
|
TanStack Query. WS client não implementado (ver nota abaixo) —
|
||||||
- [ ] Todas as telas do checklist de aceite (seção 90)
|
telas ao vivo usam polling via `refetchInterval` (5–15s conforme a
|
||||||
|
tela), suficiente para o volume atual mas não é WebSocket real.
|
||||||
|
- [x] Logo processada (`b2bcall.png` copiada, original nunca modificada) +
|
||||||
|
tema light/dark com toggle manual + `prefers-color-scheme`.
|
||||||
|
- [x] Menu completo (seção 52) com gating por permissão real
|
||||||
|
(`RequirePermission` + itens de menu ocultos por `useAuth().can()`),
|
||||||
|
Dashboard/Discador/Call Center/Telefonia/Monitoramento/
|
||||||
|
Relatórios/Sistema + Console do Agente.
|
||||||
|
- [x] Reverse proxy Nginx (`infrastructure/nginx/`) colocando frontend e
|
||||||
|
API na mesma origem (porta 80, único serviço publicado à LAN) —
|
||||||
|
antecipado da Fase 9 (seção 87) porque sem isso não havia como um
|
||||||
|
navegador de verdade alcançar a API (que não publica porta própria).
|
||||||
|
- [x] Todas as telas do checklist de aceite (seção 90) implementadas e
|
||||||
|
conectadas a endpoints reais: login, usuários, perfis/permissões,
|
||||||
|
ramais, status de ramal, troncos, dialplan, filas, agentes,
|
||||||
|
associação agente↔fila, motivos de pausa, console do agente
|
||||||
|
(login/pausa/retirar pausa), campanhas (CPS, importação CSV,
|
||||||
|
iniciar/pausar), monitoramento (discagem/agentes/fila) ao vivo,
|
||||||
|
TME/TMA, busca de chamadas com filtro por fila/agente/estado/
|
||||||
|
telefone/data, exportação CSV, administração do Asterisk
|
||||||
|
(status + diagnóstico allowlist + reload), auditoria.
|
||||||
|
Adicionado `queueId` a `GET /api/reports/calls` (não existia antes)
|
||||||
|
para permitir o filtro por fila explicitamente pedido na seção 90.
|
||||||
|
- [ ] Callbacks (menu da seção 52) — **não implementado nesta fase**: o
|
||||||
|
modelo `Callback` existe no schema desde a Fase 6, mas nunca houve
|
||||||
|
controller/service de API para ele, e a seção 90 (critério literal de
|
||||||
|
aceite) não exige essa tela. Criar uma tela sem a API por trás seria
|
||||||
|
construir um mockup, o que a diretriz do projeto proíbe. Fica
|
||||||
|
registrado como pendência explícita, não descartado silenciosamente.
|
||||||
|
- [ ] **Verificação visual em navegador não foi possível nesta sessão** —
|
||||||
|
ambiente é um servidor Debian headless sem display/browser. A
|
||||||
|
verificação feita foi: `tsc --noEmit` limpo, `eslint` limpo, build de
|
||||||
|
produção (`next build`) gerando as 27 rotas sem erro, e testes
|
||||||
|
funcionais via `curl` reproduzindo exatamente as chamadas que o
|
||||||
|
navegador faria — login via `/api/auth/login`, cookie de sessão
|
||||||
|
validado pelo middleware (`/` redireciona para `/login` sem cookie,
|
||||||
|
libera com cookie), todas as 24 páginas protegidas retornando HTTP
|
||||||
|
200 através do Nginx, e os endpoints de dados (`/api/dashboard`,
|
||||||
|
`/api/reports/*`, `/api/compliance/*` etc.) retornando dados reais
|
||||||
|
com o mesmo cookie de sessão. O que **não** foi verificado: renderização
|
||||||
|
visual real, interações de clique/formulário no DOM, responsividade,
|
||||||
|
tema escuro na prática. Recomenda-se ao usuário abrir
|
||||||
|
`http://10.10.32.142/` em um navegador para essa validação final.
|
||||||
|
|
||||||
## Fase 9 — Segurança e produção
|
## Fase 9 — Segurança e produção
|
||||||
- [ ] Criptografia de segredos de trunk (AES-256-GCM)
|
- [ ] Criptografia de segredos de trunk (AES-256-GCM)
|
||||||
|
|||||||
@@ -77,4 +77,38 @@ export class MonitoringController {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Estado corrente real (agent_state_events sem ended_at), nunca inferido
|
||||||
|
// de cache — cada linha aqui é o snapshot atual de fato do agente.
|
||||||
|
@Get('agents')
|
||||||
|
@RequirePermissions('monitoring.view')
|
||||||
|
async agentsStatus() {
|
||||||
|
const agents = await this.prisma.agent.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: { code: 'asc' },
|
||||||
|
include: {
|
||||||
|
user: { select: { name: true } },
|
||||||
|
queues: { include: { queue: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const openEvents = await this.prisma.agentStateEvent.findMany({
|
||||||
|
where: { endedAt: null },
|
||||||
|
orderBy: { startedAt: 'desc' },
|
||||||
|
});
|
||||||
|
const stateByAgent = new Map(openEvents.map((e) => [e.agentId, e]));
|
||||||
|
|
||||||
|
return agents.map((agent) => {
|
||||||
|
const event = stateByAgent.get(agent.id);
|
||||||
|
return {
|
||||||
|
id: agent.id,
|
||||||
|
code: agent.code,
|
||||||
|
name: agent.name,
|
||||||
|
userName: agent.user.name,
|
||||||
|
currentExtension: agent.currentExtension,
|
||||||
|
queues: agent.queues.map((q) => q.queue.name),
|
||||||
|
state: event?.state ?? 'LOGGED_OUT',
|
||||||
|
stateSince: event?.startedAt ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export class QueryCallsReportDto {
|
|||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
campaignId?: string;
|
campaignId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
queueId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function buildCallsWhere(
|
|||||||
dispositionId: query.dispositionId,
|
dispositionId: query.dispositionId,
|
||||||
state: query.state as CallState | undefined,
|
state: query.state as CallState | undefined,
|
||||||
calledNumber: query.phone ? { contains: query.phone } : undefined,
|
calledNumber: query.phone ? { contains: query.phone } : undefined,
|
||||||
|
campaign: query.queueId ? { queueId: query.queueId } : undefined,
|
||||||
startedAt: {
|
startedAt: {
|
||||||
gte: query.from ? new Date(query.from) : undefined,
|
gte: query.from ? new Date(query.from) : undefined,
|
||||||
lte: query.to ? new Date(query.to) : undefined,
|
lte: query.to ? new Date(query.to) : undefined,
|
||||||
|
|||||||
14
apps/frontend/eslint.config.mjs
Normal file
14
apps/frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { FlatCompat } from '@eslint/eslintrc';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||||
|
{
|
||||||
|
ignores: ['.next/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
|
];
|
||||||
6
apps/frontend/next-env.d.ts
vendored
Normal file
6
apps/frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
9
apps/frontend/next.config.ts
Normal file
9
apps/frontend/next.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
reactStrictMode: true,
|
||||||
|
eslint: { ignoreDuringBuilds: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
46
apps/frontend/package.json
Normal file
46
apps/frontend/package.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "@b2bcall/frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --port 3001",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start --port 3001",
|
||||||
|
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^15.5.4",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
|
"zod": "^3.25.76",
|
||||||
|
"date-fns": "^4.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"@types/node": "^24.6.2",
|
||||||
|
"@types/react": "^19.2.2",
|
||||||
|
"@types/react-dom": "^19.2.1",
|
||||||
|
"eslint": "^9.38.0",
|
||||||
|
"eslint-config-next": "^15.5.4",
|
||||||
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
"tailwindcss": "^4.1.16",
|
||||||
|
"@tailwindcss/postcss": "^4.1.16"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/frontend/postcss.config.mjs
Normal file
7
apps/frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
BIN
apps/frontend/public/logo.png
Normal file
BIN
apps/frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 766 KiB |
213
apps/frontend/src/app/(app)/agente/page.tsx
Normal file
213
apps/frontend/src/app/(app)/agente/page.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { PhoneCall, Coffee, LogOut, Play } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { agentConsoleService } from '@/services/agent-console';
|
||||||
|
import { pauseReasonsService } from '@/services/pause-reasons';
|
||||||
|
import { ApiError } from '@/lib/api-client';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
import type { AgentState } from '@/types';
|
||||||
|
|
||||||
|
const STATE_LABEL: Record<AgentState, string> = {
|
||||||
|
OFFLINE: 'Offline',
|
||||||
|
LOGGED_OUT: 'Deslogado',
|
||||||
|
LOGGED_IN: 'Logado (ocioso)',
|
||||||
|
AVAILABLE: 'Disponível',
|
||||||
|
RINGING: 'Chamando',
|
||||||
|
IN_CALL: 'Em chamada',
|
||||||
|
WRAP_UP: 'Pós-atendimento',
|
||||||
|
PAUSED: 'Pausado',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE_VARIANT: Record<AgentState, 'success' | 'secondary' | 'warning' | 'destructive' | 'outline'> = {
|
||||||
|
OFFLINE: 'outline',
|
||||||
|
LOGGED_OUT: 'outline',
|
||||||
|
LOGGED_IN: 'secondary',
|
||||||
|
AVAILABLE: 'success',
|
||||||
|
RINGING: 'warning',
|
||||||
|
IN_CALL: 'warning',
|
||||||
|
WRAP_UP: 'warning',
|
||||||
|
PAUSED: 'destructive',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AgentConsolePage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [extension, setExtension] = React.useState('');
|
||||||
|
const [pauseReasonId, setPauseReasonId] = React.useState('');
|
||||||
|
|
||||||
|
const { data: me, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['agent-console-me'],
|
||||||
|
queryFn: agentConsoleService.me,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: pauseReasons } = useQuery({
|
||||||
|
queryKey: ['pause-reasons'],
|
||||||
|
queryFn: pauseReasonsService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['agent-console-me'] });
|
||||||
|
const onErr = (err: unknown) =>
|
||||||
|
toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' });
|
||||||
|
|
||||||
|
const login = useMutation({
|
||||||
|
mutationFn: () => agentConsoleService.login(extension),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast({ title: 'Login realizado', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
const available = useMutation({
|
||||||
|
mutationFn: () => agentConsoleService.available(),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast({ title: 'Você está disponível', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
const pause = useMutation({
|
||||||
|
mutationFn: () => agentConsoleService.pause(pauseReasonId),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast({ title: 'Pausa iniciada', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
const unpause = useMutation({
|
||||||
|
mutationFn: () => agentConsoleService.unpause(),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast({ title: 'Pausa encerrada', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
const logout = useMutation({
|
||||||
|
mutationFn: () => agentConsoleService.logout(),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast({ title: 'Logout realizado', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Console do Agente" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof ApiError && error.status === 403) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Console do Agente" />
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Seu usuário não possui um agente de Call Center associado.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = me?.state ?? 'OFFLINE';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Console do Agente" description={me?.agent.name} />
|
||||||
|
<Card className="max-w-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
Status atual
|
||||||
|
<Badge variant={STATE_VARIANT[state]}>{STATE_LABEL[state]}</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
{me?.currentPause && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Pausado desde {new Date(me.currentPause.startedAt).toLocaleTimeString('pt-BR')} —{' '}
|
||||||
|
{me.currentPause.pauseReason.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'OFFLINE' || state === 'LOGGED_OUT' ? (
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
|
<Label htmlFor="extension">Ramal</Label>
|
||||||
|
<Input
|
||||||
|
id="extension"
|
||||||
|
value={extension}
|
||||||
|
onChange={(e) => setExtension(e.target.value)}
|
||||||
|
placeholder="4001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button loading={login.isPending} disabled={!extension} onClick={() => login.mutate()}>
|
||||||
|
<Play /> Entrar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(state === 'LOGGED_IN' || state === 'PAUSED') && (
|
||||||
|
<Button loading={available.isPending} onClick={() => available.mutate()}>
|
||||||
|
<PhoneCall /> {state === 'PAUSED' ? 'Encerrar pausa e ficar disponível' : 'Ficar disponível'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{state === 'PAUSED' ? (
|
||||||
|
<Button variant="outline" loading={unpause.isPending} onClick={() => unpause.mutate()}>
|
||||||
|
Retirar pausa
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
state !== 'IN_CALL' &&
|
||||||
|
state !== 'RINGING' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={pauseReasonId} onValueChange={setPauseReasonId}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue placeholder="Motivo da pausa..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(pauseReasons ?? []).map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!pauseReasonId}
|
||||||
|
loading={pause.isPending}
|
||||||
|
onClick={() => pause.mutate()}
|
||||||
|
>
|
||||||
|
<Coffee /> Pausar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<Button variant="destructive" loading={logout.isPending} onClick={() => logout.mutate()}>
|
||||||
|
<LogOut /> Logout do agente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
apps/frontend/src/app/(app)/agentes/page.tsx
Normal file
262
apps/frontend/src/app/(app)/agentes/page.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Trash2, Pencil } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { agentsService, type AgentInput } from '@/services/agents';
|
||||||
|
import { usersService } from '@/services/users';
|
||||||
|
import type { Agent } from '@/types';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
|
||||||
|
function AgentFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
agent,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
agent: Agent | null;
|
||||||
|
}) {
|
||||||
|
const isEdit = Boolean(agent);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [form, setForm] = React.useState<AgentInput>({ code: '', name: '', userId: '' });
|
||||||
|
|
||||||
|
const { data: users } = useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: usersService.list,
|
||||||
|
enabled: open && !isEdit,
|
||||||
|
});
|
||||||
|
const { data: existingAgents } = useQuery({
|
||||||
|
queryKey: ['agents'],
|
||||||
|
queryFn: agentsService.list,
|
||||||
|
enabled: open && !isEdit,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setForm(agent ? { name: agent.name, active: agent.active } : { code: '', name: '', userId: '' });
|
||||||
|
}
|
||||||
|
}, [open, agent]);
|
||||||
|
|
||||||
|
const linkedUserIds = new Set((existingAgents ?? []).map((a) => a.userId));
|
||||||
|
const availableUsers = (users ?? []).filter((u) => !linkedUserIds.has(u.id));
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
isEdit && agent ? agentsService.update(agent.id, form) : agentsService.create(form),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||||
|
toast({ title: 'Agente salvo', variant: 'success' });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Editar agente' : 'Novo agente'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
{!isEdit && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="code">Código</Label>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
required
|
||||||
|
value={form.code ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="name">Nome</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isEdit && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Usuário vinculado</Label>
|
||||||
|
<Select
|
||||||
|
value={form.userId}
|
||||||
|
onValueChange={(v) => setForm((f) => ({ ...f, userId: v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecione um usuário..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableUsers.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>
|
||||||
|
{u.name} ({u.email})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" loading={mutation.isPending}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentsContent() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [editing, setEditing] = React.useState<Agent | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['agents'],
|
||||||
|
queryFn: agentsService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => agentsService.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agents'] });
|
||||||
|
toast({ title: 'Agente removido', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<Agent>[] = [
|
||||||
|
{ key: 'code', header: 'Código', render: (r) => r.code },
|
||||||
|
{ key: 'name', header: 'Nome', render: (r) => r.name },
|
||||||
|
{ key: 'user', header: 'Usuário', render: (r) => r.user?.email ?? '—' },
|
||||||
|
{
|
||||||
|
key: 'queues',
|
||||||
|
header: 'Filas',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(r.queues ?? []).map((q) => (
|
||||||
|
<Badge key={q.queue.id} variant="secondary">
|
||||||
|
{q.queue.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{(r.queues ?? []).length === 0 && '—'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'active',
|
||||||
|
header: 'Status',
|
||||||
|
render: (r) => (
|
||||||
|
<Badge variant={r.active ? 'success' : 'secondary'}>{r.active ? 'Ativo' : 'Inativo'}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{can('agents.update') && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(r);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{can('agents.delete') && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Remover o agente ${r.name}?`)) remove.mutate(r.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Agentes"
|
||||||
|
description="Agentes de Call Center e sua associação a filas"
|
||||||
|
actions={
|
||||||
|
can('agents.create') && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus /> Novo agente
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
emptyMessage="Nenhum agente cadastrado."
|
||||||
|
/>
|
||||||
|
<AgentFormDialog open={dialogOpen} onOpenChange={setDialogOpen} agent={editing} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentsPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="agents.view">
|
||||||
|
<AgentsContent />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
apps/frontend/src/app/(app)/asterisk/page.tsx
Normal file
139
apps/frontend/src/app/(app)/asterisk/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { RefreshCw, Terminal } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { asteriskService } from '@/services/asterisk';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
|
||||||
|
function Content() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [selectedCommand, setSelectedCommand] = React.useState('');
|
||||||
|
const [output, setOutput] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({
|
||||||
|
queryKey: ['asterisk-status'],
|
||||||
|
queryFn: asteriskService.status,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
const { data: allowedCommands } = useQuery({
|
||||||
|
queryKey: ['asterisk-allowed-commands'],
|
||||||
|
queryFn: asteriskService.allowedCommands,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runDiagnostic = useMutation({
|
||||||
|
mutationFn: (command: string) => asteriskService.runDiagnostic(command),
|
||||||
|
onSuccess: (result) => setOutput(result.output),
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reload = useMutation({
|
||||||
|
mutationFn: () => asteriskService.reload(),
|
||||||
|
onSuccess: () => toast({ title: 'Reload solicitado', variant: 'success' }),
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Asterisk"
|
||||||
|
description="Status da conexão AMI e diagnóstico do servidor Asterisk"
|
||||||
|
actions={
|
||||||
|
can('asterisk.reload') && (
|
||||||
|
<Button variant="outline" loading={reload.isPending} onClick={() => reload.mutate()}>
|
||||||
|
<RefreshCw /> Reload
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between pt-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Conexão AMI (controle)</p>
|
||||||
|
<p className="mt-1 text-sm font-medium">
|
||||||
|
{statusLoading ? '...' : status?.amiControlConnection}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={status?.amiControlConnection === 'up' ? 'success' : 'destructive'}>
|
||||||
|
{status?.amiControlConnection ?? '—'}
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between pt-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">apps/asterisk-events (heartbeat)</p>
|
||||||
|
<p className="mt-1 text-sm font-medium">{status?.lastHeartbeat ?? 'sem sinal'}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={status?.asteriskEventsHeartbeat === 'up' ? 'success' : 'destructive'}>
|
||||||
|
{status?.asteriskEventsHeartbeat ?? '—'}
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Diagnóstico (comandos AMI allowlist)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Select value={selectedCommand} onValueChange={setSelectedCommand}>
|
||||||
|
<SelectTrigger className="w-80">
|
||||||
|
<SelectValue placeholder="Selecione um comando..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(allowedCommands ?? []).map((cmd) => (
|
||||||
|
<SelectItem key={cmd} value={cmd}>
|
||||||
|
{cmd}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
disabled={!selectedCommand}
|
||||||
|
loading={runDiagnostic.isPending}
|
||||||
|
onClick={() => runDiagnostic.mutate(selectedCommand)}
|
||||||
|
>
|
||||||
|
<Terminal /> Executar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-4 font-mono text-xs">
|
||||||
|
{output ?? 'A saída do comando aparecerá aqui.'}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => refetchStatus()}>
|
||||||
|
<RefreshCw className="size-3.5" /> Atualizar status
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AsteriskPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="asterisk.view">
|
||||||
|
<Content />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
apps/frontend/src/app/(app)/auditoria/page.tsx
Normal file
137
apps/frontend/src/app/(app)/auditoria/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { auditService } from '@/services/audit';
|
||||||
|
import type { AuditLogEntry } from '@/types';
|
||||||
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Content() {
|
||||||
|
const [action, setAction] = React.useState('');
|
||||||
|
const [entityType, setEntityType] = React.useState('');
|
||||||
|
const [from, setFrom] = React.useState('');
|
||||||
|
const [to, setTo] = React.useState('');
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
const [detail, setDetail] = React.useState<AuditLogEntry | null>(null);
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
action: action || undefined,
|
||||||
|
entityType: entityType || undefined,
|
||||||
|
from: from || undefined,
|
||||||
|
to: to || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['audit', query, page],
|
||||||
|
queryFn: () => auditService.query({ ...query, page, pageSize: 25 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<AuditLogEntry>[] = [
|
||||||
|
{ key: 'createdAt', header: 'Data', render: (r) => formatDateTime(r.createdAt) },
|
||||||
|
{ key: 'user', header: 'Usuário', render: (r) => r.user?.email ?? r.userId ?? '—' },
|
||||||
|
{ key: 'action', header: 'Ação', render: (r) => r.action },
|
||||||
|
{ key: 'entityType', header: 'Entidade', render: (r) => r.entityType ?? '—' },
|
||||||
|
{ key: 'ipAddress', header: 'IP', render: (r) => r.ipAddress ?? '—' },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
render: (r) => (
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => setDetail(r)}>
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Auditoria" description="Trilha de auditoria de ações no sistema" />
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardContent className="grid grid-cols-2 gap-3 pt-5 sm:grid-cols-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Ação</Label>
|
||||||
|
<Input value={action} onChange={(e) => { setAction(e.target.value); setPage(1); }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Entidade</Label>
|
||||||
|
<Input value={entityType} onChange={(e) => { setEntityType(e.target.value); setPage(1); }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>De</Label>
|
||||||
|
<Input type="datetime-local" value={from} onChange={(e) => { setFrom(e.target.value); setPage(1); }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Até</Label>
|
||||||
|
<Input type="datetime-local" value={to} onChange={(e) => { setTo(e.target.value); setPage(1); }} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data?.items}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
page={data?.page ?? page}
|
||||||
|
pageSize={data?.pageSize ?? 25}
|
||||||
|
total={data?.total ?? 0}
|
||||||
|
onPageChange={setPage}
|
||||||
|
emptyMessage="Nenhum registro de auditoria encontrado."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(detail)} onOpenChange={(v) => !v && setDetail(null)}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Detalhes do evento</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{detail && (
|
||||||
|
<div className="flex flex-col gap-3 text-sm">
|
||||||
|
<p><strong>Ação:</strong> {detail.action}</p>
|
||||||
|
<p><strong>Entidade:</strong> {detail.entityType ?? '—'} {detail.entityId ? `(${detail.entityId})` : ''}</p>
|
||||||
|
<p><strong>Usuário:</strong> {detail.user?.email ?? detail.userId ?? '—'}</p>
|
||||||
|
<p><strong>IP:</strong> {detail.ipAddress ?? '—'}</p>
|
||||||
|
<p><strong>User agent:</strong> {detail.userAgent ?? '—'}</p>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 font-medium">Antes</p>
|
||||||
|
<pre className="max-h-40 overflow-auto rounded-md bg-muted p-3 font-mono text-xs">
|
||||||
|
{JSON.stringify(detail.before, null, 2) || '—'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 font-medium">Depois</p>
|
||||||
|
<pre className="max-h-40 overflow-auto rounded-md bg-muted p-3 font-mono text-xs">
|
||||||
|
{JSON.stringify(detail.after, null, 2) || '—'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="audit.view">
|
||||||
|
<Content />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
apps/frontend/src/app/(app)/bloqueio/page.tsx
Normal file
202
apps/frontend/src/app/(app)/bloqueio/page.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Trash2, UploadCloud } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { useDebounce } from '@/hooks/use-debounce';
|
||||||
|
import { suppressionService } from '@/services/suppression';
|
||||||
|
import type { SuppressionEntry } from '@/types';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
function AddDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [phone, setPhone] = React.useState('');
|
||||||
|
const [reason, setReason] = React.useState('');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setPhone('');
|
||||||
|
setReason('');
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => suppressionService.add(phone, reason || undefined),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['suppression'] });
|
||||||
|
toast({ title: 'Número bloqueado', variant: 'success' });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bloquear número</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="phone">Telefone</Label>
|
||||||
|
<Input id="phone" required value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="reason">Motivo (opcional)</Label>
|
||||||
|
<Input id="reason" value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" loading={mutation.isPending}>
|
||||||
|
Bloquear
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuppressionContent() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [search, setSearch] = React.useState('');
|
||||||
|
const debouncedSearch = useDebounce(search);
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['suppression', debouncedSearch, page],
|
||||||
|
queryFn: () => suppressionService.query({ search: debouncedSearch || undefined, page, pageSize: 25 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => suppressionService.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['suppression'] });
|
||||||
|
toast({ title: 'Número desbloqueado', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const importCsv = useMutation({
|
||||||
|
mutationFn: (file: File) => suppressionService.importCsv(file),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['suppression'] });
|
||||||
|
toast({
|
||||||
|
title: 'Importação concluída',
|
||||||
|
description: `${result.added} adicionados, ${result.invalid} inválidos de ${result.total}.`,
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<SuppressionEntry>[] = [
|
||||||
|
{ key: 'phoneNormalized', header: 'Telefone', render: (r) => r.phoneNormalized },
|
||||||
|
{ key: 'reason', header: 'Motivo', render: (r) => r.reason ?? '—' },
|
||||||
|
{ key: 'createdAt', header: 'Adicionado em', render: (r) => formatDateTime(r.createdAt) },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
render: (r) =>
|
||||||
|
can('campaigns.update') ? (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Desbloquear ${r.phoneNormalized}?`)) remove.mutate(r.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Lista de Bloqueio"
|
||||||
|
description="Números que nunca devem ser discados (suppression list)"
|
||||||
|
actions={
|
||||||
|
can('campaigns.update') && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) importCsv.mutate(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" loading={importCsv.isPending} onClick={() => fileInputRef.current?.click()}>
|
||||||
|
<UploadCloud /> Importar CSV
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
|
<Plus /> Bloquear número
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data?.items}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
searchValue={search}
|
||||||
|
onSearchChange={(v) => {
|
||||||
|
setSearch(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
searchPlaceholder="Buscar por telefone..."
|
||||||
|
page={data?.page ?? page}
|
||||||
|
pageSize={data?.pageSize ?? 25}
|
||||||
|
total={data?.total ?? 0}
|
||||||
|
onPageChange={setPage}
|
||||||
|
emptyMessage="Nenhum número bloqueado."
|
||||||
|
/>
|
||||||
|
<AddDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SuppressionPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="campaigns.view">
|
||||||
|
<SuppressionContent />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
apps/frontend/src/app/(app)/campanhas/[id]/page.tsx
Normal file
211
apps/frontend/src/app/(app)/campanhas/[id]/page.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Play, Pause, Square, Users, FileUp } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { campaignsService } from '@/services/campaigns';
|
||||||
|
import { dashboardService } from '@/services/dashboard';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
import { formatPercent, formatSeconds } from '@/lib/utils';
|
||||||
|
|
||||||
|
function MetricCard({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold">{value}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CampaignDetailContent({ id }: { id: string }) {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [cpsDraft, setCpsDraft] = React.useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: campaign, isLoading } = useQuery({
|
||||||
|
queryKey: ['campaign', id],
|
||||||
|
queryFn: () => campaignsService.get(id),
|
||||||
|
});
|
||||||
|
const { data: live } = useQuery({
|
||||||
|
queryKey: ['campaign-dashboard', id],
|
||||||
|
queryFn: () => dashboardService.campaign(id),
|
||||||
|
refetchInterval: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['campaign', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['campaign-dashboard', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||||
|
};
|
||||||
|
const onErr = (err: unknown) =>
|
||||||
|
toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' });
|
||||||
|
|
||||||
|
const start = useMutation({
|
||||||
|
mutationFn: () => campaignsService.start(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast({ title: 'Campanha iniciada', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
const pause = useMutation({
|
||||||
|
mutationFn: () => campaignsService.pause(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast({ title: 'Campanha pausada', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
const stop = useMutation({
|
||||||
|
mutationFn: () => campaignsService.stop(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast({ title: 'Campanha parada', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
const updateCps = useMutation({
|
||||||
|
mutationFn: (maxCps: number) => campaignsService.update(id, { maxCps }),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
setCpsDraft(null);
|
||||||
|
toast({ title: 'CPS atualizado', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !campaign) {
|
||||||
|
return <Skeleton className="h-64 w-full" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={campaign.name}
|
||||||
|
description={`Status: ${campaign.status}`}
|
||||||
|
actions={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{can('campaigns.start') &&
|
||||||
|
['DRAFT', 'READY', 'PAUSED', 'STOPPED'].includes(campaign.status) && (
|
||||||
|
<Button loading={start.isPending} onClick={() => start.mutate()}>
|
||||||
|
<Play /> Iniciar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{can('campaigns.pause') && campaign.status === 'RUNNING' && (
|
||||||
|
<Button variant="outline" loading={pause.isPending} onClick={() => pause.mutate()}>
|
||||||
|
<Pause /> Pausar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{can('campaigns.stop') && ['RUNNING', 'PAUSED'].includes(campaign.status) && (
|
||||||
|
<Button variant="destructive" loading={stop.isPending} onClick={() => stop.mutate()}>
|
||||||
|
<Square /> Parar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-6 flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/leads?campaignId=${id}`}>
|
||||||
|
<Users /> Ver leads
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/importacoes?campaignId=${id}`}>
|
||||||
|
<FileUp /> Importar leads
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
<MetricCard label="CPS atual" value={live?.cpsAtual ?? 0} />
|
||||||
|
<MetricCard label="Discando" value={live?.dialing ?? 0} />
|
||||||
|
<MetricCard label="Tocando" value={live?.ringing ?? 0} />
|
||||||
|
<MetricCard label="Na fila (agente)" value={live?.queued ?? 0} />
|
||||||
|
<MetricCard label="Conectadas" value={live?.connected ?? 0} />
|
||||||
|
<MetricCard label="Leads restantes" value={live?.leadsRemaining ?? 0} />
|
||||||
|
<MetricCard label="Leads processados" value={live?.leadsProcessed ?? 0} />
|
||||||
|
<MetricCard
|
||||||
|
label="Fator de pacing"
|
||||||
|
value={live?.pacingFactor !== null && live?.pacingFactor !== undefined ? live.pacingFactor.toFixed(2) : '—'}
|
||||||
|
/>
|
||||||
|
<MetricCard label="Prob. de atendimento" value={formatPercent(live?.answerProbability)} />
|
||||||
|
<MetricCard label="Taxa de abandono" value={formatPercent(live?.abandonRate)} />
|
||||||
|
<MetricCard label="TMA médio" value={formatSeconds(live?.avgTalkTimeSeconds)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mt-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Configuração</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap items-end gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="maxCps">CPS máximo</Label>
|
||||||
|
<Input
|
||||||
|
id="maxCps"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-32"
|
||||||
|
value={cpsDraft ?? campaign.maxCps}
|
||||||
|
onChange={(e) => setCpsDraft(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{can('campaigns.update') && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={cpsDraft === null || cpsDraft === campaign.maxCps}
|
||||||
|
loading={updateCps.isPending}
|
||||||
|
onClick={() => cpsDraft !== null && updateCps.mutate(cpsDraft)}
|
||||||
|
>
|
||||||
|
Salvar CPS
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Concorrência máxima</p>
|
||||||
|
<p className="text-sm font-medium">{campaign.maxConcurrentCalls}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Janela de discagem</p>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{campaign.startTime}–{campaign.endTime} ({campaign.timezone})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Máx. tentativas</p>
|
||||||
|
<p className="text-sm font-medium">{campaign.maxAttempts}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Badge variant={campaign.amdEnabled ? 'success' : 'secondary'}>
|
||||||
|
AMD {campaign.amdEnabled ? 'ativo' : 'inativo'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CampaignDetailPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="campaigns.view">
|
||||||
|
<CampaignDetailContent id={params.id} />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
324
apps/frontend/src/app/(app)/campanhas/page.tsx
Normal file
324
apps/frontend/src/app/(app)/campanhas/page.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Play, Pause, Square, Trash2, ArrowRightCircle } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { campaignsService, type CampaignInput } from '@/services/campaigns';
|
||||||
|
import { queuesService } from '@/services/queues';
|
||||||
|
import { trunksService } from '@/services/trunks';
|
||||||
|
import type { Campaign, CampaignStatus } from '@/types';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<CampaignStatus, 'success' | 'secondary' | 'warning' | 'destructive' | 'outline'> = {
|
||||||
|
DRAFT: 'outline',
|
||||||
|
READY: 'secondary',
|
||||||
|
RUNNING: 'success',
|
||||||
|
PAUSED: 'warning',
|
||||||
|
DRAINING: 'warning',
|
||||||
|
STOPPED: 'secondary',
|
||||||
|
COMPLETED: 'secondary',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_FORM: CampaignInput = {
|
||||||
|
name: '',
|
||||||
|
queueId: '',
|
||||||
|
trunkId: '',
|
||||||
|
maxCps: 2,
|
||||||
|
maxConcurrentCalls: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
function CreateCampaignDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [form, setForm] = React.useState<CampaignInput>(EMPTY_FORM);
|
||||||
|
|
||||||
|
const { data: queues } = useQuery({ queryKey: ['queues'], queryFn: queuesService.list, enabled: open });
|
||||||
|
const { data: trunks } = useQuery({ queryKey: ['trunks'], queryFn: trunksService.list, enabled: open });
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) setForm(EMPTY_FORM);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => campaignsService.create(form),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||||
|
toast({ title: 'Campanha criada', variant: 'success' });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Nova campanha</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="name">Nome</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Fila</Label>
|
||||||
|
<Select value={form.queueId} onValueChange={(v) => setForm((f) => ({ ...f, queueId: v }))}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecione..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(queues ?? []).map((q) => (
|
||||||
|
<SelectItem key={q.id} value={q.id}>
|
||||||
|
{q.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Tronco</Label>
|
||||||
|
<Select value={form.trunkId} onValueChange={(v) => setForm((f) => ({ ...f, trunkId: v }))}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecione..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(trunks ?? []).map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="maxCps">CPS máximo</Label>
|
||||||
|
<Input
|
||||||
|
id="maxCps"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
required
|
||||||
|
value={form.maxCps}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, maxCps: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="maxConcurrentCalls">Concorrência máx.</Label>
|
||||||
|
<Input
|
||||||
|
id="maxConcurrentCalls"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
required
|
||||||
|
value={form.maxConcurrentCalls}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, maxConcurrentCalls: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="callerId">Caller ID</Label>
|
||||||
|
<Input
|
||||||
|
id="callerId"
|
||||||
|
value={form.callerId ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, callerId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" loading={mutation.isPending}>
|
||||||
|
Criar campanha
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CampaignsContent() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['campaigns'],
|
||||||
|
queryFn: campaignsService.list,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||||
|
const onErr = (err: unknown) =>
|
||||||
|
toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' });
|
||||||
|
|
||||||
|
const start = useMutation({
|
||||||
|
mutationFn: (id: string) => campaignsService.start(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast({ title: 'Campanha iniciada', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
const pause = useMutation({
|
||||||
|
mutationFn: (id: string) => campaignsService.pause(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast({ title: 'Campanha pausada', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
const stop = useMutation({
|
||||||
|
mutationFn: (id: string) => campaignsService.stop(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast({ title: 'Campanha parada', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => campaignsService.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
toast({ title: 'Campanha removida', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: onErr,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<Campaign>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Nome',
|
||||||
|
render: (r) => (
|
||||||
|
<Link href={`/campanhas/${r.id}`} className="font-medium hover:underline">
|
||||||
|
{r.name}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
render: (r) => <Badge variant={STATUS_VARIANT[r.status]}>{r.status}</Badge>,
|
||||||
|
},
|
||||||
|
{ key: 'maxCps', header: 'CPS', render: (r) => r.maxCps },
|
||||||
|
{ key: 'maxConcurrentCalls', header: 'Concorrência', render: (r) => r.maxConcurrentCalls },
|
||||||
|
{ key: 'schedule', header: 'Janela', render: (r) => `${r.startTime}–${r.endTime}` },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{can('campaigns.start') && (r.status === 'DRAFT' || r.status === 'READY' || r.status === 'PAUSED' || r.status === 'STOPPED') && (
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => start.mutate(r.id)} title="Iniciar">
|
||||||
|
<Play className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{can('campaigns.pause') && r.status === 'RUNNING' && (
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => pause.mutate(r.id)} title="Pausar">
|
||||||
|
<Pause className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{can('campaigns.stop') && (r.status === 'RUNNING' || r.status === 'PAUSED') && (
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => stop.mutate(r.id)} title="Parar">
|
||||||
|
<Square className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="icon" variant="ghost" asChild title="Detalhes">
|
||||||
|
<Link href={`/campanhas/${r.id}`}>
|
||||||
|
<ArrowRightCircle className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{can('campaigns.delete') && r.status === 'DRAFT' && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Remover a campanha ${r.name}?`)) remove.mutate(r.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Campanhas"
|
||||||
|
description="Campanhas do discador preditivo"
|
||||||
|
actions={
|
||||||
|
can('campaigns.create') && (
|
||||||
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
|
<Plus /> Nova campanha
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
emptyMessage="Nenhuma campanha cadastrada."
|
||||||
|
/>
|
||||||
|
<CreateCampaignDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CampaignsPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="campaigns.view">
|
||||||
|
<CampaignsContent />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
apps/frontend/src/app/(app)/compliance/page.tsx
Normal file
205
apps/frontend/src/app/(app)/compliance/page.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Save, AlertTriangle } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { complianceService, type UpdateComplianceInput } from '@/services/compliance';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
|
||||||
|
function SettingsCard() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [form, setForm] = React.useState<UpdateComplianceInput>({});
|
||||||
|
|
||||||
|
const { data: settings, isLoading } = useQuery({
|
||||||
|
queryKey: ['compliance-settings'],
|
||||||
|
queryFn: complianceService.getSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (settings) {
|
||||||
|
setForm({
|
||||||
|
shortCallThresholdSeconds: settings.shortCallThresholdSeconds,
|
||||||
|
maxAttemptsPerNumberPerDay: settings.maxAttemptsPerNumberPerDay,
|
||||||
|
maxAttemptsPerNumberPerMonth: settings.maxAttemptsPerNumberPerMonth,
|
||||||
|
highVolumeMonthlyThreshold: settings.highVolumeMonthlyThreshold,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => complianceService.updateSettings(form),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['compliance-settings'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['compliance-indicators'] });
|
||||||
|
toast({ title: 'Configuração salva', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Parâmetros de Compliance</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
className="grid grid-cols-1 gap-4 sm:grid-cols-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="shortCall">Limiar de chamada curta (s)</Label>
|
||||||
|
<Input
|
||||||
|
id="shortCall"
|
||||||
|
type="number"
|
||||||
|
disabled={!can('settings.manage')}
|
||||||
|
value={form.shortCallThresholdSeconds ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, shortCallThresholdSeconds: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="maxDay">Máx. tentativas por número/dia</Label>
|
||||||
|
<Input
|
||||||
|
id="maxDay"
|
||||||
|
type="number"
|
||||||
|
disabled={!can('settings.manage')}
|
||||||
|
value={form.maxAttemptsPerNumberPerDay ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, maxAttemptsPerNumberPerDay: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="maxMonth">Máx. tentativas por número/mês</Label>
|
||||||
|
<Input
|
||||||
|
id="maxMonth"
|
||||||
|
type="number"
|
||||||
|
disabled={!can('settings.manage')}
|
||||||
|
value={form.maxAttemptsPerNumberPerMonth ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, maxAttemptsPerNumberPerMonth: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="highVolume">Limiar de alto volume mensal</Label>
|
||||||
|
<Input
|
||||||
|
id="highVolume"
|
||||||
|
type="number"
|
||||||
|
disabled={!can('settings.manage')}
|
||||||
|
value={form.highVolumeMonthlyThreshold ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, highVolumeMonthlyThreshold: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{can('settings.manage') && (
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Button type="submit" loading={mutation.isPending}>
|
||||||
|
<Save /> Salvar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndicatorsCard() {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['compliance-indicators'],
|
||||||
|
queryFn: complianceService.indicators,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Indicadores (hoje / mês)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Chamadas hoje</p>
|
||||||
|
<p className="text-xl font-semibold">{data.totalCallsToday}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Atendidas hoje</p>
|
||||||
|
<p className="text-xl font-semibold">{data.answeredCallsToday}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Chamadas curtas hoje</p>
|
||||||
|
<p className="text-xl font-semibold">{data.shortCallsToday}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Abandonadas hoje</p>
|
||||||
|
<p className="text-xl font-semibold">{data.abandonedCallsToday}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Chamadas no mês</p>
|
||||||
|
<p className="text-xl font-semibold">{data.totalCallsMonth}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.alerts.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{data.alerts.map((a, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 rounded-md border border-warning/40 bg-warning/10 p-3 text-sm">
|
||||||
|
<AlertTriangle className="size-4 text-warning" />
|
||||||
|
{a}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.numbersOverDailyLimit.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium">Números acima do limite diário</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{data.numbersOverDailyLimit.map((n) => (
|
||||||
|
<Badge key={n.phoneNormalized} variant="destructive">
|
||||||
|
{n.phoneNormalized} ({n.attempts})
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompliancePage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="settings.manage">
|
||||||
|
<PageHeader
|
||||||
|
title="Compliance"
|
||||||
|
description="Parâmetros configuráveis e indicadores de compliance de discagem"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<IndicatorsCard />
|
||||||
|
<SettingsCard />
|
||||||
|
</div>
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
360
apps/frontend/src/app/(app)/dialplan/page.tsx
Normal file
360
apps/frontend/src/app/(app)/dialplan/page.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Trash2, Pencil, UploadCloud, RotateCcw } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { dialplanService, type DialplanEntryInput } from '@/services/dialplan';
|
||||||
|
import type { DialplanEntry, DialplanVersion } from '@/types';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
const EMPTY_FORM: DialplanEntryInput = {
|
||||||
|
context: '',
|
||||||
|
exten: '',
|
||||||
|
priority: 1,
|
||||||
|
application: '',
|
||||||
|
argument: '',
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function EntryFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
entry,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
entry: DialplanEntry | null;
|
||||||
|
}) {
|
||||||
|
const isEdit = Boolean(entry);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [form, setForm] = React.useState<DialplanEntryInput>(EMPTY_FORM);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setForm(
|
||||||
|
entry
|
||||||
|
? {
|
||||||
|
context: entry.context,
|
||||||
|
exten: entry.exten,
|
||||||
|
priority: entry.priority,
|
||||||
|
application: entry.application,
|
||||||
|
argument: entry.argument ?? '',
|
||||||
|
enabled: entry.enabled,
|
||||||
|
}
|
||||||
|
: EMPTY_FORM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [open, entry]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
isEdit && entry
|
||||||
|
? dialplanService.update(entry.id, form)
|
||||||
|
: dialplanService.create(form),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dialplan-entries'] });
|
||||||
|
toast({ title: 'Entrada salva', variant: 'success' });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Editar entrada' : 'Nova entrada de dialplan'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="context">Contexto</Label>
|
||||||
|
<Input
|
||||||
|
id="context"
|
||||||
|
required
|
||||||
|
value={form.context}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, context: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="exten">Extensão</Label>
|
||||||
|
<Input
|
||||||
|
id="exten"
|
||||||
|
required
|
||||||
|
value={form.exten}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, exten: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="priority">Prioridade</Label>
|
||||||
|
<Input
|
||||||
|
id="priority"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
required
|
||||||
|
value={form.priority}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, priority: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="application">Aplicação</Label>
|
||||||
|
<Input
|
||||||
|
id="application"
|
||||||
|
required
|
||||||
|
placeholder="Dial, Answer, Hangup..."
|
||||||
|
value={form.application}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, application: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="argument">Argumento</Label>
|
||||||
|
<Input
|
||||||
|
id="argument"
|
||||||
|
value={form.argument ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, argument: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" loading={mutation.isPending}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntriesTab() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [editing, setEditing] = React.useState<DialplanEntry | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['dialplan-entries'],
|
||||||
|
queryFn: () => dialplanService.list(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => dialplanService.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dialplan-entries'] });
|
||||||
|
toast({ title: 'Entrada removida', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const publish = useMutation({
|
||||||
|
mutationFn: () => dialplanService.publish(),
|
||||||
|
onSuccess: (version) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dialplan-versions'] });
|
||||||
|
toast({
|
||||||
|
title: version.status === 'APPLIED' ? 'Dialplan publicado' : 'Falha ao publicar',
|
||||||
|
description: version.reloadResult ?? undefined,
|
||||||
|
variant: version.status === 'APPLIED' ? 'success' : 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<DialplanEntry>[] = [
|
||||||
|
{ key: 'context', header: 'Contexto', render: (r) => r.context },
|
||||||
|
{ key: 'exten', header: 'Extensão', render: (r) => r.exten },
|
||||||
|
{ key: 'priority', header: 'Prio.', render: (r) => r.priority },
|
||||||
|
{ key: 'application', header: 'Aplicação', render: (r) => r.application },
|
||||||
|
{ key: 'argument', header: 'Argumento', render: (r) => r.argument ?? '—' },
|
||||||
|
{
|
||||||
|
key: 'enabled',
|
||||||
|
header: 'Status',
|
||||||
|
render: (r) => (
|
||||||
|
<Badge variant={r.enabled ? 'success' : 'secondary'}>
|
||||||
|
{r.enabled ? 'Ativa' : 'Inativa'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{can('dialplans.update') && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(r);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{can('dialplans.delete') && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Remover esta entrada de dialplan?')) remove.mutate(r.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{can('dialplans.update') && (
|
||||||
|
<Button variant="outline" loading={publish.isPending} onClick={() => publish.mutate()}>
|
||||||
|
<UploadCloud /> Publicar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{can('dialplans.create') && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus /> Nova entrada
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
emptyMessage="Nenhuma entrada de dialplan cadastrada."
|
||||||
|
/>
|
||||||
|
<EntryFormDialog open={dialogOpen} onOpenChange={setDialogOpen} entry={editing} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VersionsTab() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['dialplan-versions'],
|
||||||
|
queryFn: dialplanService.versions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rollback = useMutation({
|
||||||
|
mutationFn: (id: string) => dialplanService.rollback(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dialplan-versions'] });
|
||||||
|
toast({ title: 'Rollback aplicado', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<DialplanVersion>[] = [
|
||||||
|
{ key: 'createdAt', header: 'Data', render: (r) => formatDateTime(r.createdAt) },
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
render: (r) => (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
r.status === 'APPLIED' ? 'success' : r.status === 'FAILED' ? 'destructive' : 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{r.status}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'reloadResult', header: 'Resultado', render: (r) => r.reloadResult ?? '—' },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
render: (r) =>
|
||||||
|
can('dialplans.update') && r.status === 'APPLIED' ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => rollback.mutate(r.id)}>
|
||||||
|
<RotateCcw className="size-3.5" /> Rollback
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
emptyMessage="Nenhuma versão publicada ainda."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialplanContent() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Dialplan" description="Plano de discagem estruturado e versionado" />
|
||||||
|
<Tabs defaultValue="entries">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="entries">Entradas</TabsTrigger>
|
||||||
|
<TabsTrigger value="versions">Versões</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="entries">
|
||||||
|
<EntriesTab />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="versions">
|
||||||
|
<VersionsTab />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DialplanPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="dialplans.view">
|
||||||
|
<DialplanContent />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
252
apps/frontend/src/app/(app)/disposicoes/page.tsx
Normal file
252
apps/frontend/src/app/(app)/disposicoes/page.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Trash2, Pencil } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { dispositionsService, type DispositionInput } from '@/services/dispositions';
|
||||||
|
import type { Disposition, DispositionAction } from '@/types';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
|
||||||
|
const EMPTY_FORM: DispositionInput = { name: '', code: '', action: 'NONE', active: true };
|
||||||
|
const ACTIONS: DispositionAction[] = ['NONE', 'CALLBACK', 'DO_NOT_CALL'];
|
||||||
|
const ACTION_LABEL: Record<DispositionAction, string> = {
|
||||||
|
NONE: 'Nenhuma',
|
||||||
|
CALLBACK: 'Agendar retorno',
|
||||||
|
DO_NOT_CALL: 'Não ligar mais',
|
||||||
|
};
|
||||||
|
|
||||||
|
function FormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
item: Disposition | null;
|
||||||
|
}) {
|
||||||
|
const isEdit = Boolean(item);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [form, setForm] = React.useState<DispositionInput>(EMPTY_FORM);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setForm(
|
||||||
|
item
|
||||||
|
? {
|
||||||
|
name: item.name,
|
||||||
|
description: item.description ?? '',
|
||||||
|
action: item.action,
|
||||||
|
active: item.active,
|
||||||
|
}
|
||||||
|
: EMPTY_FORM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [open, item]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
isEdit && item
|
||||||
|
? dispositionsService.update(item.id, form)
|
||||||
|
: dispositionsService.create(form),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dispositions'] });
|
||||||
|
toast({ title: 'Disposição salva', variant: 'success' });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Editar disposição' : 'Nova disposição'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="name">Nome</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isEdit && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="code">Código</Label>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
required
|
||||||
|
value={form.code ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Ação automática</Label>
|
||||||
|
<Select
|
||||||
|
value={form.action}
|
||||||
|
onValueChange={(v) => setForm((f) => ({ ...f, action: v as DispositionAction }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ACTIONS.map((a) => (
|
||||||
|
<SelectItem key={a} value={a}>
|
||||||
|
{ACTION_LABEL[a]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" loading={mutation.isPending}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DispositionsContent() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [editing, setEditing] = React.useState<Disposition | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['dispositions'],
|
||||||
|
queryFn: dispositionsService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => dispositionsService.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dispositions'] });
|
||||||
|
toast({ title: 'Disposição removida', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<Disposition>[] = [
|
||||||
|
{ key: 'name', header: 'Nome', render: (r) => r.name },
|
||||||
|
{ key: 'code', header: 'Código', render: (r) => r.code },
|
||||||
|
{ key: 'action', header: 'Ação', render: (r) => ACTION_LABEL[r.action] },
|
||||||
|
{
|
||||||
|
key: 'active',
|
||||||
|
header: 'Status',
|
||||||
|
render: (r) => (
|
||||||
|
<Badge variant={r.active ? 'success' : 'secondary'}>{r.active ? 'Ativa' : 'Inativa'}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{can('settings.manage') && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(r);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Remover a disposição ${r.name}?`)) remove.mutate(r.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Disposições"
|
||||||
|
description="Códigos de disposição de chamada usados pelos agentes"
|
||||||
|
actions={
|
||||||
|
can('settings.manage') && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus /> Nova disposição
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
emptyMessage="Nenhuma disposição cadastrada."
|
||||||
|
/>
|
||||||
|
<FormDialog open={dialogOpen} onOpenChange={setDialogOpen} item={editing} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DispositionsPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="settings.manage">
|
||||||
|
<DispositionsContent />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
400
apps/frontend/src/app/(app)/filas/page.tsx
Normal file
400
apps/frontend/src/app/(app)/filas/page.tsx
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Trash2, Pencil, Users, X } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { queuesService, type QueueInput } from '@/services/queues';
|
||||||
|
import { agentsService } from '@/services/agents';
|
||||||
|
import type { Queue, QueueStrategy } from '@/types';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
|
||||||
|
const STRATEGIES: QueueStrategy[] = [
|
||||||
|
'ringall',
|
||||||
|
'leastrecent',
|
||||||
|
'fewestcalls',
|
||||||
|
'random',
|
||||||
|
'rrmemory',
|
||||||
|
'rrordered',
|
||||||
|
'linear',
|
||||||
|
'wrandom',
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_FORM: QueueInput = { name: '', number: '', strategy: 'ringall', enabled: true };
|
||||||
|
|
||||||
|
function QueueFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
queue,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
queue: Queue | null;
|
||||||
|
}) {
|
||||||
|
const isEdit = Boolean(queue);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [form, setForm] = React.useState<QueueInput>(EMPTY_FORM);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setForm(
|
||||||
|
queue
|
||||||
|
? {
|
||||||
|
strategy: queue.strategy,
|
||||||
|
timeout: queue.timeout,
|
||||||
|
wrapUpTime: queue.wrapUpTime,
|
||||||
|
maxLen: queue.maxLen,
|
||||||
|
enabled: queue.enabled,
|
||||||
|
}
|
||||||
|
: EMPTY_FORM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [open, queue]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
isEdit && queue ? queuesService.update(queue.id, form) : queuesService.create(form),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['queues'] });
|
||||||
|
toast({ title: 'Fila salva', variant: 'success' });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Editar fila' : 'Nova fila'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
{!isEdit && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="name">Nome</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
required
|
||||||
|
value={form.name ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="number">Número</Label>
|
||||||
|
<Input
|
||||||
|
id="number"
|
||||||
|
required
|
||||||
|
value={form.number ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, number: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Estratégia</Label>
|
||||||
|
<Select
|
||||||
|
value={form.strategy}
|
||||||
|
onValueChange={(v) => setForm((f) => ({ ...f, strategy: v as QueueStrategy }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STRATEGIES.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="timeout">Timeout de toque (s)</Label>
|
||||||
|
<Input
|
||||||
|
id="timeout"
|
||||||
|
type="number"
|
||||||
|
value={form.timeout ?? 15}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, timeout: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="wrapUpTime">Wrap-up (s)</Label>
|
||||||
|
<Input
|
||||||
|
id="wrapUpTime"
|
||||||
|
type="number"
|
||||||
|
value={form.wrapUpTime ?? 0}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, wrapUpTime: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" loading={mutation.isPending}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MembersDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
queue,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
queue: Queue | null;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [selectedAgentId, setSelectedAgentId] = React.useState('');
|
||||||
|
|
||||||
|
const { data: agents } = useQuery({
|
||||||
|
queryKey: ['agents'],
|
||||||
|
queryFn: agentsService.list,
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
const { data: fullQueue, refetch } = useQuery({
|
||||||
|
queryKey: ['queue-detail', queue?.id],
|
||||||
|
queryFn: () => queuesService.get(queue!.id),
|
||||||
|
enabled: open && Boolean(queue),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addMember = useMutation({
|
||||||
|
mutationFn: () => queuesService.addMember(queue!.id, selectedAgentId),
|
||||||
|
onSuccess: () => {
|
||||||
|
refetch();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['queues'] });
|
||||||
|
setSelectedAgentId('');
|
||||||
|
toast({ title: 'Agente associado', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMember = useMutation({
|
||||||
|
mutationFn: (agentId: string) => queuesService.removeMember(queue!.id, agentId),
|
||||||
|
onSuccess: () => {
|
||||||
|
refetch();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['queues'] });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberIds = new Set((fullQueue?.members ?? []).map((m) => m.agentId));
|
||||||
|
const availableAgents = (agents ?? []).filter((a) => !memberIds.has(a.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Agentes da fila {queue?.name}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={selectedAgentId} onValueChange={setSelectedAgentId}>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="Selecione um agente..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableAgents.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.code} — {a.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
disabled={!selectedAgentId}
|
||||||
|
loading={addMember.isPending}
|
||||||
|
onClick={() => addMember.mutate()}
|
||||||
|
>
|
||||||
|
<Plus /> Adicionar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{(fullQueue?.members ?? []).map((m) => (
|
||||||
|
<li
|
||||||
|
key={m.agentId}
|
||||||
|
className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{m.agent?.name ?? m.agentId}
|
||||||
|
{m.penalty ? ` (penalidade ${m.penalty})` : ''}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => removeMember.mutate(m.agentId)}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{(fullQueue?.members ?? []).length === 0 && (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
Nenhum agente associado.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QueuesContent() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [membersOpen, setMembersOpen] = React.useState(false);
|
||||||
|
const [editing, setEditing] = React.useState<Queue | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['queues'],
|
||||||
|
queryFn: queuesService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => queuesService.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['queues'] });
|
||||||
|
toast({ title: 'Fila removida', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<Queue>[] = [
|
||||||
|
{ key: 'name', header: 'Nome', render: (r) => r.name },
|
||||||
|
{ key: 'number', header: 'Número', render: (r) => r.number },
|
||||||
|
{ key: 'strategy', header: 'Estratégia', render: (r) => r.strategy },
|
||||||
|
{
|
||||||
|
key: 'enabled',
|
||||||
|
header: 'Status',
|
||||||
|
render: (r) => (
|
||||||
|
<Badge variant={r.enabled ? 'success' : 'secondary'}>
|
||||||
|
{r.enabled ? 'Ativa' : 'Inativa'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{can('queues.update') && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(r);
|
||||||
|
setMembersOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Users className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(r);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{can('queues.delete') && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Remover a fila ${r.name}?`)) remove.mutate(r.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Filas"
|
||||||
|
description="Filas de atendimento (ACD)"
|
||||||
|
actions={
|
||||||
|
can('queues.create') && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus /> Nova fila
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
emptyMessage="Nenhuma fila cadastrada."
|
||||||
|
/>
|
||||||
|
<QueueFormDialog open={dialogOpen} onOpenChange={setDialogOpen} queue={editing} />
|
||||||
|
<MembersDialog open={membersOpen} onOpenChange={setMembersOpen} queue={editing} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QueuesPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="queues.view">
|
||||||
|
<QueuesContent />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
apps/frontend/src/app/(app)/importacoes/page.tsx
Normal file
148
apps/frontend/src/app/(app)/importacoes/page.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { UploadCloud, Download } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { CampaignSelect } from '@/components/campaign-select';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { leadsService } from '@/services/leads';
|
||||||
|
import type { LeadImport } from '@/types';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
function downloadBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportsContent() {
|
||||||
|
const params = useSearchParams();
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [campaignId, setCampaignId] = React.useState(params.get('campaignId') ?? '');
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['lead-imports', campaignId],
|
||||||
|
queryFn: () => leadsService.imports(campaignId),
|
||||||
|
enabled: Boolean(campaignId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = useMutation({
|
||||||
|
mutationFn: (file: File) => leadsService.importCsv(campaignId, file),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['lead-imports', campaignId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['leads'] });
|
||||||
|
toast({
|
||||||
|
title: 'Importação concluída',
|
||||||
|
description: `${result.valid} válidos, ${result.invalid} inválidos, ${result.duplicate} duplicados de ${result.total} linhas.`,
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro na importação', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadRejected = useMutation({
|
||||||
|
mutationFn: (importId: string) => leadsService.downloadRejected(campaignId, importId),
|
||||||
|
onSuccess: (blob) => downloadBlob(blob, 'rejeitados.csv'),
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<LeadImport>[] = [
|
||||||
|
{ key: 'filename', header: 'Arquivo', render: (r) => r.filename },
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
render: (r) => <Badge variant="secondary">{r.status}</Badge>,
|
||||||
|
},
|
||||||
|
{ key: 'totalRows', header: 'Total', render: (r) => r.totalRows },
|
||||||
|
{ key: 'validRows', header: 'Válidos', render: (r) => r.validRows },
|
||||||
|
{ key: 'invalidRows', header: 'Inválidos', render: (r) => r.invalidRows },
|
||||||
|
{ key: 'duplicateRows', header: 'Duplicados', render: (r) => r.duplicateRows },
|
||||||
|
{ key: 'createdAt', header: 'Data', render: (r) => formatDateTime(r.createdAt) },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
render: (r) =>
|
||||||
|
r.invalidRows > 0 ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => downloadRejected.mutate(r.id)}>
|
||||||
|
<Download className="size-3.5" /> Rejeitados
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Importações" description="Importação de leads via CSV por campanha" />
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<CampaignSelect value={campaignId} onChange={setCampaignId} />
|
||||||
|
{campaignId && can('campaigns.update') && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) upload.mutate(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button loading={upload.isPending} onClick={() => fileInputRef.current?.click()}>
|
||||||
|
<UploadCloud /> Importar CSV
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!campaignId ? (
|
||||||
|
<p className="py-16 text-center text-sm text-muted-foreground">
|
||||||
|
Selecione uma campanha para ver o histórico de importações.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
emptyMessage="Nenhuma importação realizada ainda."
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImportsPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="campaigns.view">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ImportsContent />
|
||||||
|
</Suspense>
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
apps/frontend/src/app/(app)/layout.tsx
Normal file
5
apps/frontend/src/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { AppShell } from '@/components/layout/app-shell';
|
||||||
|
|
||||||
|
export default function AppGroupLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <AppShell>{children}</AppShell>;
|
||||||
|
}
|
||||||
151
apps/frontend/src/app/(app)/leads/page.tsx
Normal file
151
apps/frontend/src/app/(app)/leads/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { CampaignSelect } from '@/components/campaign-select';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { leadsService } from '@/services/leads';
|
||||||
|
import type { Lead, LeadStatus } from '@/types';
|
||||||
|
import { useDebounce } from '@/hooks/use-debounce';
|
||||||
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
const STATUSES: LeadStatus[] = [
|
||||||
|
'NEW',
|
||||||
|
'READY',
|
||||||
|
'RESERVED',
|
||||||
|
'DIALING',
|
||||||
|
'RINGING',
|
||||||
|
'BUSY',
|
||||||
|
'NO_ANSWER',
|
||||||
|
'FAILED',
|
||||||
|
'COMPLETED',
|
||||||
|
'MAX_ATTEMPTS',
|
||||||
|
'DO_NOT_CALL',
|
||||||
|
'INVALID',
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<string, 'success' | 'secondary' | 'warning' | 'destructive' | 'outline'> = {
|
||||||
|
COMPLETED: 'success',
|
||||||
|
READY: 'secondary',
|
||||||
|
NEW: 'outline',
|
||||||
|
DIALING: 'warning',
|
||||||
|
RINGING: 'warning',
|
||||||
|
BUSY: 'warning',
|
||||||
|
NO_ANSWER: 'warning',
|
||||||
|
FAILED: 'destructive',
|
||||||
|
MAX_ATTEMPTS: 'destructive',
|
||||||
|
DO_NOT_CALL: 'destructive',
|
||||||
|
INVALID: 'destructive',
|
||||||
|
RESERVED: 'secondary',
|
||||||
|
};
|
||||||
|
|
||||||
|
function LeadsContent() {
|
||||||
|
const params = useSearchParams();
|
||||||
|
const [campaignId, setCampaignId] = React.useState(params.get('campaignId') ?? '');
|
||||||
|
const [search, setSearch] = React.useState('');
|
||||||
|
const debouncedSearch = useDebounce(search);
|
||||||
|
const [status, setStatus] = React.useState<string>('all');
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['leads', campaignId, debouncedSearch, status, page],
|
||||||
|
queryFn: () =>
|
||||||
|
leadsService.query(campaignId, {
|
||||||
|
search: debouncedSearch || undefined,
|
||||||
|
status: status === 'all' ? undefined : (status as LeadStatus),
|
||||||
|
page,
|
||||||
|
pageSize: 25,
|
||||||
|
}),
|
||||||
|
enabled: Boolean(campaignId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<Lead>[] = [
|
||||||
|
{ key: 'name', header: 'Nome', render: (r) => r.name ?? '—' },
|
||||||
|
{ key: 'phone', header: 'Telefone', render: (r) => r.phone },
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
render: (r) => <Badge variant={STATUS_VARIANT[r.status] ?? 'outline'}>{r.status}</Badge>,
|
||||||
|
},
|
||||||
|
{ key: 'attemptCount', header: 'Tentativas', render: (r) => r.attemptCount },
|
||||||
|
{ key: 'lastAttemptAt', header: 'Última tentativa', render: (r) => formatDateTime(r.lastAttemptAt) },
|
||||||
|
{ key: 'nextAttemptAt', header: 'Próxima tentativa', render: (r) => formatDateTime(r.nextAttemptAt) },
|
||||||
|
{ key: 'lastResult', header: 'Último resultado', render: (r) => r.lastResult ?? '—' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Leads" description="Leads de uma campanha do discador" />
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<CampaignSelect
|
||||||
|
value={campaignId}
|
||||||
|
onChange={(v) => {
|
||||||
|
setCampaignId(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select value={status} onValueChange={(v) => { setStatus(v); setPage(1); }}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos os status</SelectItem>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!campaignId ? (
|
||||||
|
<p className="py-16 text-center text-sm text-muted-foreground">
|
||||||
|
Selecione uma campanha para ver os leads.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data?.items}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
searchValue={search}
|
||||||
|
onSearchChange={(v) => {
|
||||||
|
setSearch(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
searchPlaceholder="Buscar por nome ou telefone..."
|
||||||
|
page={data?.page ?? page}
|
||||||
|
pageSize={data?.pageSize ?? 25}
|
||||||
|
total={data?.total ?? 0}
|
||||||
|
onPageChange={setPage}
|
||||||
|
emptyMessage="Nenhum lead encontrado para os filtros selecionados."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LeadsPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="campaigns.view">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<LeadsContent />
|
||||||
|
</Suspense>
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
apps/frontend/src/app/(app)/monitoramento/agentes/page.tsx
Normal file
91
apps/frontend/src/app/(app)/monitoramento/agentes/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { monitoringService } from '@/services/monitoring';
|
||||||
|
import type { AgentMonitor, AgentState } from '@/types';
|
||||||
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
const STATE_VARIANT: Record<AgentState, 'success' | 'secondary' | 'warning' | 'destructive' | 'outline'> = {
|
||||||
|
AVAILABLE: 'success',
|
||||||
|
IN_CALL: 'warning',
|
||||||
|
RINGING: 'warning',
|
||||||
|
WRAP_UP: 'warning',
|
||||||
|
PAUSED: 'destructive',
|
||||||
|
LOGGED_IN: 'secondary',
|
||||||
|
LOGGED_OUT: 'outline',
|
||||||
|
OFFLINE: 'outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE_LABEL: Record<AgentState, string> = {
|
||||||
|
AVAILABLE: 'Disponível',
|
||||||
|
IN_CALL: 'Em chamada',
|
||||||
|
RINGING: 'Chamando',
|
||||||
|
WRAP_UP: 'Pós-atendimento',
|
||||||
|
PAUSED: 'Pausado',
|
||||||
|
LOGGED_IN: 'Logado',
|
||||||
|
LOGGED_OUT: 'Deslogado',
|
||||||
|
OFFLINE: 'Offline',
|
||||||
|
};
|
||||||
|
|
||||||
|
function Content() {
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['monitoring-agents'],
|
||||||
|
queryFn: monitoringService.agents,
|
||||||
|
refetchInterval: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<AgentMonitor>[] = [
|
||||||
|
{ key: 'code', header: 'Código', render: (r) => r.code },
|
||||||
|
{ key: 'name', header: 'Nome', render: (r) => r.name },
|
||||||
|
{ key: 'currentExtension', header: 'Ramal', render: (r) => r.currentExtension ?? '—' },
|
||||||
|
{
|
||||||
|
key: 'queues',
|
||||||
|
header: 'Filas',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{r.queues.map((q) => (
|
||||||
|
<Badge key={q} variant="secondary">
|
||||||
|
{q}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'state',
|
||||||
|
header: 'Estado',
|
||||||
|
render: (r) => <Badge variant={STATE_VARIANT[r.state]}>{STATE_LABEL[r.state]}</Badge>,
|
||||||
|
},
|
||||||
|
{ key: 'stateSince', header: 'Desde', render: (r) => formatDateTime(r.stateSince) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Monitoramento de Agentes"
|
||||||
|
description="Estado corrente de cada agente (atualiza a cada 5s)"
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
emptyMessage="Nenhum agente ativo."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonitoringAgentsPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="monitoring.view">
|
||||||
|
<Content />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
apps/frontend/src/app/(app)/monitoramento/campanhas/page.tsx
Normal file
107
apps/frontend/src/app/(app)/monitoramento/campanhas/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { campaignsService } from '@/services/campaigns';
|
||||||
|
import { dashboardService } from '@/services/dashboard';
|
||||||
|
import { formatPercent, formatSeconds } from '@/lib/utils';
|
||||||
|
|
||||||
|
const ACTIVE_STATUSES = new Set(['RUNNING', 'PAUSED', 'DRAINING']);
|
||||||
|
|
||||||
|
function Content() {
|
||||||
|
const { data: campaigns, isLoading } = useQuery({
|
||||||
|
queryKey: ['campaigns'],
|
||||||
|
queryFn: campaignsService.list,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const active = (campaigns ?? []).filter((c) => ACTIVE_STATUSES.has(c.status));
|
||||||
|
|
||||||
|
const liveQueries = useQueries({
|
||||||
|
queries: active.map((c) => ({
|
||||||
|
queryKey: ['campaign-dashboard', c.id],
|
||||||
|
queryFn: () => dashboardService.campaign(c.id),
|
||||||
|
refetchInterval: 5_000,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-40 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="py-16 text-center text-sm text-muted-foreground">
|
||||||
|
Nenhuma campanha em execução, pausada ou drenando no momento.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{active.map((campaign, idx) => {
|
||||||
|
const live = liveQueries[idx]?.data;
|
||||||
|
return (
|
||||||
|
<Card key={campaign.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<Link href={`/campanhas/${campaign.id}`} className="hover:underline">
|
||||||
|
{campaign.name}
|
||||||
|
</Link>
|
||||||
|
<Badge variant={campaign.status === 'RUNNING' ? 'success' : 'warning'}>
|
||||||
|
{campaign.status}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">CPS atual</p>
|
||||||
|
<p className="font-medium">{live?.cpsAtual ?? '—'} / {campaign.maxCps}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Conectadas</p>
|
||||||
|
<p className="font-medium">{live?.connected ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Leads restantes</p>
|
||||||
|
<p className="font-medium">{live?.leadsRemaining ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Abandono</p>
|
||||||
|
<p className="font-medium">{formatPercent(live?.abandonRate ?? undefined)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">TMA médio</p>
|
||||||
|
<p className="font-medium">{formatSeconds(live?.avgTalkTimeSeconds ?? undefined)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Pacing</p>
|
||||||
|
<p className="font-medium">{live?.pacingFactor?.toFixed(2) ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonitoringCampaignsPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="monitoring.view">
|
||||||
|
<PageHeader title="Monitoramento de Campanhas" description="Campanhas ativas em tempo real" />
|
||||||
|
<Content />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/frontend/src/app/(app)/monitoramento/filas/page.tsx
Normal file
61
apps/frontend/src/app/(app)/monitoramento/filas/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { monitoringService } from '@/services/monitoring';
|
||||||
|
import type { QueueMonitor } from '@/types';
|
||||||
|
import { formatSeconds } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Content() {
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['monitoring-queues'],
|
||||||
|
queryFn: monitoringService.queues,
|
||||||
|
refetchInterval: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<QueueMonitor>[] = [
|
||||||
|
{ key: 'name', header: 'Fila', render: (r) => r.name },
|
||||||
|
{ key: 'strategy', header: 'Estratégia', render: (r) => r.strategy },
|
||||||
|
{
|
||||||
|
key: 'callsWaiting',
|
||||||
|
header: 'Aguardando',
|
||||||
|
render: (r) => (
|
||||||
|
<Badge variant={r.callsWaiting > 0 ? 'warning' : 'secondary'}>{r.callsWaiting}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'longestWaitSeconds',
|
||||||
|
header: 'Maior espera',
|
||||||
|
render: (r) => formatSeconds(r.longestWaitSeconds),
|
||||||
|
},
|
||||||
|
{ key: 'agentsLoggedIn', header: 'Agentes logados', render: (r) => r.agentsLoggedIn },
|
||||||
|
{ key: 'agentsAvailable', header: 'Disponíveis', render: (r) => r.agentsAvailable },
|
||||||
|
{ key: 'agentsPaused', header: 'Pausados', render: (r) => r.agentsPaused },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Monitoramento de Filas" description="Estado em tempo real das filas (atualiza a cada 5s)" />
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
emptyMessage="Nenhuma fila ativa."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonitoringQueuesPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="monitoring.view">
|
||||||
|
<Content />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
apps/frontend/src/app/(app)/monitoramento/ramais/page.tsx
Normal file
73
apps/frontend/src/app/(app)/monitoramento/ramais/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { monitoringService } from '@/services/monitoring';
|
||||||
|
import type { ExtensionMonitor } from '@/types';
|
||||||
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<string, 'success' | 'secondary' | 'warning' | 'destructive'> = {
|
||||||
|
online: 'success',
|
||||||
|
busy: 'warning',
|
||||||
|
offline: 'destructive',
|
||||||
|
unknown: 'secondary',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
online: 'Online',
|
||||||
|
busy: 'Em uso',
|
||||||
|
offline: 'Offline',
|
||||||
|
unknown: 'Desconhecido',
|
||||||
|
};
|
||||||
|
|
||||||
|
function Content() {
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['monitoring-extensions'],
|
||||||
|
queryFn: monitoringService.extensions,
|
||||||
|
refetchInterval: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<ExtensionMonitor>[] = [
|
||||||
|
{ key: 'number', header: 'Ramal', render: (r) => r.number },
|
||||||
|
{ key: 'name', header: 'Nome', render: (r) => r.name },
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
render: (r) => (
|
||||||
|
<Badge variant={STATUS_VARIANT[r.status] ?? 'secondary'}>{STATUS_LABEL[r.status] ?? r.status}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'deviceState', header: 'Device State', render: (r) => r.deviceState ?? '—' },
|
||||||
|
{ key: 'contactStatus', header: 'Contact Status', render: (r) => r.contactStatus ?? '—' },
|
||||||
|
{ key: 'updatedAt', header: 'Atualizado em', render: (r) => formatDateTime(r.updatedAt) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Monitoramento de Ramais"
|
||||||
|
description="Estado dos ramais SIP (device state / contact status), atualiza a cada 5s"
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.number}
|
||||||
|
emptyMessage="Nenhum ramal cadastrado."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonitoringExtensionsPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="monitoring.view">
|
||||||
|
<Content />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
apps/frontend/src/app/(app)/page.tsx
Normal file
167
apps/frontend/src/app/(app)/page.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
PhoneCall,
|
||||||
|
PhoneOutgoing,
|
||||||
|
Users,
|
||||||
|
Clock,
|
||||||
|
BarChart3,
|
||||||
|
PhoneOff,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
} from 'recharts';
|
||||||
|
import { dashboardService } from '@/services/dashboard';
|
||||||
|
import { formatPercent, formatSeconds } from '@/lib/utils';
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-4 pt-5">
|
||||||
|
<span className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton className="mt-1 h-6 w-16" />
|
||||||
|
) : (
|
||||||
|
<p className="text-xl font-semibold">{value}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardContent() {
|
||||||
|
const overview = useQuery({
|
||||||
|
queryKey: ['dashboard', 'overview'],
|
||||||
|
queryFn: dashboardService.overview,
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
const callsByHour = useQuery({
|
||||||
|
queryKey: ['dashboard', 'calls-by-hour'],
|
||||||
|
queryFn: dashboardService.callsByHour,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = overview.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
label="Chamadas hoje"
|
||||||
|
value={data?.callsToday ?? 0}
|
||||||
|
icon={PhoneOutgoing}
|
||||||
|
isLoading={overview.isLoading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Atendidas hoje"
|
||||||
|
value={data?.answeredToday ?? 0}
|
||||||
|
icon={PhoneCall}
|
||||||
|
isLoading={overview.isLoading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Agentes disponíveis"
|
||||||
|
value={data?.agentsAvailable ?? 0}
|
||||||
|
icon={Users}
|
||||||
|
isLoading={overview.isLoading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Aguardando agente"
|
||||||
|
value={data?.waitingForAgent ?? 0}
|
||||||
|
icon={Clock}
|
||||||
|
isLoading={overview.isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
label="TME médio"
|
||||||
|
value={formatSeconds(data?.tmeSeconds)}
|
||||||
|
icon={Clock}
|
||||||
|
isLoading={overview.isLoading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="TMA médio"
|
||||||
|
value={formatSeconds(data?.tmaSeconds)}
|
||||||
|
icon={BarChart3}
|
||||||
|
isLoading={overview.isLoading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Taxa de atendimento"
|
||||||
|
value={formatPercent(data?.answerRate)}
|
||||||
|
icon={PhoneCall}
|
||||||
|
isLoading={overview.isLoading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Taxa de abandono"
|
||||||
|
value={formatPercent(data?.abandonRate)}
|
||||||
|
icon={PhoneOff}
|
||||||
|
isLoading={overview.isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Chamadas por hora (hoje)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{callsByHour.isLoading ? (
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
) : callsByHour.data && callsByHour.data.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={callsByHour.data}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis dataKey="hour" tickFormatter={(h) => `${h}h`} fontSize={12} />
|
||||||
|
<YAxis fontSize={12} allowDecimals={false} />
|
||||||
|
<RechartsTooltip
|
||||||
|
labelFormatter={(h) => `${h}h`}
|
||||||
|
contentStyle={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="total" name="Total" fill="var(--color-primary)" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="answered" name="Atendidas" fill="var(--color-success)" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Nenhuma chamada registrada hoje ainda.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="dashboard.view">
|
||||||
|
<PageHeader title="Dashboard" description="Visão geral em tempo real da operação" />
|
||||||
|
<DashboardContent />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
apps/frontend/src/app/(app)/pausas/page.tsx
Normal file
254
apps/frontend/src/app/(app)/pausas/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Trash2, Pencil } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { pauseReasonsService, type PauseReasonInput } from '@/services/pause-reasons';
|
||||||
|
import type { PauseReason } from '@/types';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
|
||||||
|
const EMPTY_FORM: PauseReasonInput = { name: '', code: '', paid: false, active: true };
|
||||||
|
|
||||||
|
function FormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
item: PauseReason | null;
|
||||||
|
}) {
|
||||||
|
const isEdit = Boolean(item);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [form, setForm] = React.useState<PauseReasonInput>(EMPTY_FORM);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setForm(
|
||||||
|
item
|
||||||
|
? {
|
||||||
|
name: item.name,
|
||||||
|
description: item.description ?? '',
|
||||||
|
maxDurationSeconds: item.maxDurationSeconds ?? undefined,
|
||||||
|
paid: item.paid,
|
||||||
|
active: item.active,
|
||||||
|
}
|
||||||
|
: EMPTY_FORM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [open, item]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
isEdit && item
|
||||||
|
? pauseReasonsService.update(item.id, form)
|
||||||
|
: pauseReasonsService.create(form),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pause-reasons'] });
|
||||||
|
toast({ title: 'Motivo de pausa salvo', variant: 'success' });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Editar motivo de pausa' : 'Novo motivo de pausa'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="name">Nome</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isEdit && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="code">Código</Label>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
required
|
||||||
|
value={form.code ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="maxDurationSeconds">Duração máxima (s, opcional)</Label>
|
||||||
|
<Input
|
||||||
|
id="maxDurationSeconds"
|
||||||
|
type="number"
|
||||||
|
value={form.maxDurationSeconds ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
maxDurationSeconds: e.target.value ? Number(e.target.value) : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
|
||||||
|
<Label htmlFor="paid">Pausa remunerada</Label>
|
||||||
|
<Switch
|
||||||
|
id="paid"
|
||||||
|
checked={form.paid ?? false}
|
||||||
|
onCheckedChange={(v) => setForm((f) => ({ ...f, paid: v }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" loading={mutation.isPending}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PauseReasonsContent() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [editing, setEditing] = React.useState<PauseReason | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['pause-reasons'],
|
||||||
|
queryFn: pauseReasonsService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => pauseReasonsService.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pause-reasons'] });
|
||||||
|
toast({ title: 'Motivo removido', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<PauseReason>[] = [
|
||||||
|
{ key: 'name', header: 'Nome', render: (r) => r.name },
|
||||||
|
{ key: 'code', header: 'Código', render: (r) => r.code },
|
||||||
|
{
|
||||||
|
key: 'maxDurationSeconds',
|
||||||
|
header: 'Duração máx.',
|
||||||
|
render: (r) => (r.maxDurationSeconds ? `${r.maxDurationSeconds}s` : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'paid',
|
||||||
|
header: 'Remunerada',
|
||||||
|
render: (r) => <Badge variant={r.paid ? 'success' : 'secondary'}>{r.paid ? 'Sim' : 'Não'}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'active',
|
||||||
|
header: 'Status',
|
||||||
|
render: (r) => (
|
||||||
|
<Badge variant={r.active ? 'success' : 'secondary'}>{r.active ? 'Ativo' : 'Inativo'}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{can('settings.manage') && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(r);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Remover o motivo ${r.name}?`)) remove.mutate(r.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Motivos de Pausa"
|
||||||
|
description="Motivos disponíveis para pausa dos agentes"
|
||||||
|
actions={
|
||||||
|
can('settings.manage') && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus /> Novo motivo
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
emptyMessage="Nenhum motivo de pausa cadastrado."
|
||||||
|
/>
|
||||||
|
<FormDialog open={dialogOpen} onOpenChange={setDialogOpen} item={editing} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PauseReasonsPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="settings.manage">
|
||||||
|
<PauseReasonsContent />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
apps/frontend/src/app/(app)/perfis/page.tsx
Normal file
239
apps/frontend/src/app/(app)/perfis/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Trash2, Pencil } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { rolesService } from '@/services/roles';
|
||||||
|
import type { Role } from '@/types';
|
||||||
|
import type { Permission } from '@/lib/permissions';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
|
||||||
|
function groupPermissions(permissions: Permission[]): Record<string, Permission[]> {
|
||||||
|
const groups: Record<string, Permission[]> = {};
|
||||||
|
for (const p of permissions) {
|
||||||
|
const prefix = p.split('.')[0] ?? p;
|
||||||
|
(groups[prefix] ??= []).push(p);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
role,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
role: Role | null;
|
||||||
|
}) {
|
||||||
|
const isEdit = Boolean(role);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [name, setName] = React.useState('');
|
||||||
|
const [description, setDescription] = React.useState('');
|
||||||
|
const [permissionKeys, setPermissionKeys] = React.useState<Permission[]>([]);
|
||||||
|
|
||||||
|
const { data: catalog } = useQuery({
|
||||||
|
queryKey: ['permission-catalog'],
|
||||||
|
queryFn: rolesService.permissionCatalog,
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(role?.name ?? '');
|
||||||
|
setDescription(role?.description ?? '');
|
||||||
|
setPermissionKeys((role?.permissions ?? []) as Permission[]);
|
||||||
|
}
|
||||||
|
}, [open, role]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
isEdit && role
|
||||||
|
? rolesService.update(role.id, { description, permissionKeys })
|
||||||
|
: rolesService.create({ name, description, permissionKeys }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['roles'] });
|
||||||
|
toast({ title: 'Perfil salvo', variant: 'success' });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = groupPermissions(catalog ?? []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Editar perfil' : 'Novo perfil'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="name">Nome</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
required
|
||||||
|
disabled={isEdit}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="description">Descrição</Label>
|
||||||
|
<Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Label>Permissões</Label>
|
||||||
|
<div className="grid max-h-80 grid-cols-2 gap-4 overflow-y-auto rounded-md border border-border p-3">
|
||||||
|
{Object.entries(groups).map(([group, perms]) => (
|
||||||
|
<div key={group} className="flex flex-col gap-1.5">
|
||||||
|
<p className="text-xs font-semibold uppercase text-muted-foreground">{group}</p>
|
||||||
|
{perms.map((p) => (
|
||||||
|
<label key={p} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={permissionKeys.includes(p)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setPermissionKeys((prev) =>
|
||||||
|
checked ? [...prev, p] : prev.filter((k) => k !== p),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{p}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" loading={mutation.isPending}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Content() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [editing, setEditing] = React.useState<Role | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['roles'],
|
||||||
|
queryFn: rolesService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => rolesService.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['roles'] });
|
||||||
|
toast({ title: 'Perfil removido', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<Role>[] = [
|
||||||
|
{ key: 'name', header: 'Nome', render: (r) => r.name },
|
||||||
|
{ key: 'description', header: 'Descrição', render: (r) => r.description ?? '—' },
|
||||||
|
{ key: 'permissions', header: 'Permissões', render: (r) => `${r.permissions.length} permissões` },
|
||||||
|
{
|
||||||
|
key: 'isSystem',
|
||||||
|
header: 'Tipo',
|
||||||
|
render: (r) => <Badge variant={r.isSystem ? 'secondary' : 'outline'}>{r.isSystem ? 'Sistema' : 'Customizado'}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(r);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
{!r.isSystem && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Remover o perfil ${r.name}?`)) remove.mutate(r.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Perfis e Permissões"
|
||||||
|
description="Perfis (roles) e o catálogo de permissões do RBAC"
|
||||||
|
actions={
|
||||||
|
<Button onClick={() => { setEditing(null); setDialogOpen(true); }}>
|
||||||
|
<Plus /> Novo perfil
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
emptyMessage="Nenhum perfil cadastrado."
|
||||||
|
/>
|
||||||
|
<RoleFormDialog open={dialogOpen} onOpenChange={setDialogOpen} role={editing} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RolesPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="roles.manage">
|
||||||
|
<Content />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
296
apps/frontend/src/app/(app)/ramais/page.tsx
Normal file
296
apps/frontend/src/app/(app)/ramais/page.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, KeyRound, Trash2, Pencil, Copy } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { useDebounce } from '@/hooks/use-debounce';
|
||||||
|
import { extensionsService, type ExtensionInput } from '@/services/extensions';
|
||||||
|
import type { Extension } from '@/types';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
function ExtensionFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
extension,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
extension: Extension | null;
|
||||||
|
}) {
|
||||||
|
const isEdit = Boolean(extension);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [form, setForm] = React.useState<ExtensionInput>({
|
||||||
|
number: '',
|
||||||
|
name: '',
|
||||||
|
callerId: '',
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
const [createdPassword, setCreatedPassword] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setCreatedPassword(null);
|
||||||
|
setForm(
|
||||||
|
extension
|
||||||
|
? {
|
||||||
|
name: extension.name,
|
||||||
|
callerId: extension.callerId ?? '',
|
||||||
|
enabled: extension.enabled,
|
||||||
|
}
|
||||||
|
: { number: '', name: '', callerId: '', enabled: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [open, extension]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
isEdit && extension
|
||||||
|
? extensionsService.update(extension.id, form)
|
||||||
|
: extensionsService.create(form),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['extensions'] });
|
||||||
|
if (!isEdit && 'sipPassword' in result && result.sipPassword) {
|
||||||
|
setCreatedPassword(result.sipPassword);
|
||||||
|
toast({ title: 'Ramal criado', variant: 'success' });
|
||||||
|
} else {
|
||||||
|
toast({ title: 'Ramal salvo', variant: 'success' });
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Editar ramal' : 'Novo ramal'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{createdPassword ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Senha SIP gerada (exibida apenas uma vez, copie agora):
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-border bg-muted p-2 font-mono text-sm">
|
||||||
|
<span className="flex-1 break-all">{createdPassword}</span>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => navigator.clipboard.writeText(createdPassword)}
|
||||||
|
>
|
||||||
|
<Copy className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => onOpenChange(false)}>Fechar</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
{!isEdit && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="number">Número</Label>
|
||||||
|
<Input
|
||||||
|
id="number"
|
||||||
|
required
|
||||||
|
value={form.number ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, number: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="name">Nome</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="callerId">Caller ID</Label>
|
||||||
|
<Input
|
||||||
|
id="callerId"
|
||||||
|
value={form.callerId ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, callerId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" loading={mutation.isPending}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExtensionsContent() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [search, setSearch] = React.useState('');
|
||||||
|
const debouncedSearch = useDebounce(search);
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [editing, setEditing] = React.useState<Extension | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['extensions'],
|
||||||
|
queryFn: extensionsService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetPassword = useMutation({
|
||||||
|
mutationFn: (id: string) => extensionsService.resetPassword(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['extensions'] });
|
||||||
|
toast({ title: 'Senha SIP redefinida', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => extensionsService.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['extensions'] });
|
||||||
|
toast({ title: 'Ramal removido', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = (data ?? []).filter(
|
||||||
|
(e) =>
|
||||||
|
e.number.includes(debouncedSearch) ||
|
||||||
|
e.name.toLowerCase().includes(debouncedSearch.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: DataTableColumn<Extension>[] = [
|
||||||
|
{ key: 'number', header: 'Ramal', render: (r) => r.number },
|
||||||
|
{ key: 'name', header: 'Nome', render: (r) => r.name },
|
||||||
|
{ key: 'context', header: 'Contexto', render: (r) => r.context },
|
||||||
|
{
|
||||||
|
key: 'enabled',
|
||||||
|
header: 'Status',
|
||||||
|
render: (r) => (
|
||||||
|
<Badge variant={r.enabled ? 'success' : 'secondary'}>
|
||||||
|
{r.enabled ? 'Ativo' : 'Inativo'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'updatedAt', header: 'Atualizado em', render: (r) => formatDateTime(r.updatedAt) },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{can('extensions.update') && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(r);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Redefinir a senha SIP do ramal ${r.number}?`)) {
|
||||||
|
resetPassword.mutate(r.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KeyRound className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{can('extensions.delete') && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Remover o ramal ${r.number}?`)) remove.mutate(r.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Ramais"
|
||||||
|
description="Ramais SIP dos agentes (PJSIP)"
|
||||||
|
actions={
|
||||||
|
can('extensions.create') && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus /> Novo ramal
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={filtered}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
searchValue={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Buscar por número ou nome..."
|
||||||
|
emptyMessage="Nenhum ramal cadastrado."
|
||||||
|
/>
|
||||||
|
<ExtensionFormDialog open={dialogOpen} onOpenChange={setDialogOpen} extension={editing} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExtensionsPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="extensions.view">
|
||||||
|
<ExtensionsContent />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
apps/frontend/src/app/(app)/relatorios/agentes/page.tsx
Normal file
147
apps/frontend/src/app/(app)/relatorios/agentes/page.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||||
|
import { reportsService } from '@/services/reports';
|
||||||
|
import { agentsService } from '@/services/agents';
|
||||||
|
import { formatSeconds } from '@/lib/utils';
|
||||||
|
|
||||||
|
const STATE_LABEL: Record<string, string> = {
|
||||||
|
AVAILABLE: 'Disponível',
|
||||||
|
IN_CALL: 'Em chamada',
|
||||||
|
RINGING: 'Chamando',
|
||||||
|
WRAP_UP: 'Pós-atendimento',
|
||||||
|
PAUSED: 'Pausado',
|
||||||
|
LOGGED_IN: 'Logado (ocioso)',
|
||||||
|
OFFLINE: 'Offline',
|
||||||
|
};
|
||||||
|
|
||||||
|
function Content() {
|
||||||
|
const [agentId, setAgentId] = React.useState('');
|
||||||
|
const [from, setFrom] = React.useState('');
|
||||||
|
const [to, setTo] = React.useState('');
|
||||||
|
|
||||||
|
const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: agentsService.list });
|
||||||
|
const { data: report, isLoading } = useQuery({
|
||||||
|
queryKey: ['agent-report', agentId, from, to],
|
||||||
|
queryFn: () => reportsService.agentReport(agentId, from || undefined, to || undefined),
|
||||||
|
enabled: Boolean(agentId),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Relatório de Agentes" description="Tempo por estado e pausas detalhadas" />
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardContent className="grid grid-cols-1 gap-3 pt-5 sm:grid-cols-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Agente</Label>
|
||||||
|
<Select value={agentId} onValueChange={setAgentId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Selecione..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(agents ?? []).map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>De</Label>
|
||||||
|
<Input type="datetime-local" value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Até</Label>
|
||||||
|
<Input type="datetime-local" value={to} onChange={(e) => setTo(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{!agentId ? (
|
||||||
|
<p className="py-16 text-center text-sm text-muted-foreground">Selecione um agente.</p>
|
||||||
|
) : isLoading ? (
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
) : report ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5">
|
||||||
|
<p className="text-xs text-muted-foreground">Chamadas atendidas</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold">{report.callsAnswered}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5">
|
||||||
|
<p className="text-xs text-muted-foreground">TMA médio</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold">{formatSeconds(report.tmaSeconds)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{Object.entries(report.timeByStateSeconds).map(([state, seconds]) => (
|
||||||
|
<Card key={state}>
|
||||||
|
<CardContent className="pt-5">
|
||||||
|
<p className="text-xs text-muted-foreground">{STATE_LABEL[state] ?? state}</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold">{formatSeconds(seconds)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Pausas no período</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Motivo</TableHead>
|
||||||
|
<TableHead>Início</TableHead>
|
||||||
|
<TableHead>Fim</TableHead>
|
||||||
|
<TableHead>Duração</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{report.pauses.map((p, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell>{p.reason}</TableCell>
|
||||||
|
<TableCell>{new Date(p.startedAt).toLocaleString('pt-BR')}</TableCell>
|
||||||
|
<TableCell>{p.endedAt ? new Date(p.endedAt).toLocaleString('pt-BR') : 'Em andamento'}</TableCell>
|
||||||
|
<TableCell>{formatSeconds(p.durationSeconds)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{report.pauses.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="py-6 text-center text-muted-foreground">
|
||||||
|
Nenhuma pausa no período.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentReportPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="reports.view">
|
||||||
|
<Content />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
236
apps/frontend/src/app/(app)/relatorios/chamadas/page.tsx
Normal file
236
apps/frontend/src/app/(app)/relatorios/chamadas/page.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { Download, Search } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { reportsService } from '@/services/reports';
|
||||||
|
import { campaignsService } from '@/services/campaigns';
|
||||||
|
import { queuesService } from '@/services/queues';
|
||||||
|
import { agentsService } from '@/services/agents';
|
||||||
|
import type { DialAttempt, CallState } from '@/types';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
import { formatDateTime, formatSeconds, formatPercent } from '@/lib/utils';
|
||||||
|
|
||||||
|
const STATES: CallState[] = [
|
||||||
|
'CREATED',
|
||||||
|
'RESERVED',
|
||||||
|
'ORIGINATING',
|
||||||
|
'RINGING',
|
||||||
|
'ANSWERED',
|
||||||
|
'QUEUED',
|
||||||
|
'AGENT_CONNECTED',
|
||||||
|
'COMPLETED',
|
||||||
|
'FAILED',
|
||||||
|
];
|
||||||
|
|
||||||
|
function MetricCard({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-5">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold">{value}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Content() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [filters, setFilters] = React.useState({
|
||||||
|
campaignId: 'all',
|
||||||
|
queueId: 'all',
|
||||||
|
agentId: 'all',
|
||||||
|
state: 'all',
|
||||||
|
phone: '',
|
||||||
|
from: '',
|
||||||
|
to: '',
|
||||||
|
});
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
|
||||||
|
const { data: campaigns } = useQuery({ queryKey: ['campaigns'], queryFn: campaignsService.list });
|
||||||
|
const { data: queues } = useQuery({ queryKey: ['queues'], queryFn: queuesService.list });
|
||||||
|
const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: agentsService.list });
|
||||||
|
|
||||||
|
const apiQuery = {
|
||||||
|
campaignId: filters.campaignId === 'all' ? undefined : filters.campaignId,
|
||||||
|
queueId: filters.queueId === 'all' ? undefined : filters.queueId,
|
||||||
|
agentId: filters.agentId === 'all' ? undefined : filters.agentId,
|
||||||
|
state: filters.state === 'all' ? undefined : filters.state,
|
||||||
|
phone: filters.phone || undefined,
|
||||||
|
from: filters.from || undefined,
|
||||||
|
to: filters.to || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['reports-calls', apiQuery, page],
|
||||||
|
queryFn: () => reportsService.calls({ ...apiQuery, page, pageSize: 25 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: metrics } = useQuery({
|
||||||
|
queryKey: ['reports-metrics', apiQuery.campaignId, apiQuery.from, apiQuery.to],
|
||||||
|
queryFn: () =>
|
||||||
|
reportsService.metrics({
|
||||||
|
campaignId: apiQuery.campaignId,
|
||||||
|
from: apiQuery.from,
|
||||||
|
to: apiQuery.to,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportMutation = useMutation({
|
||||||
|
mutationFn: () => reportsService.exportCalls(apiQuery),
|
||||||
|
onSuccess: (blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'relatorio-chamadas.csv';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<DialAttempt>[] = [
|
||||||
|
{ key: 'startedAt', header: 'Início', render: (r) => formatDateTime(r.startedAt) },
|
||||||
|
{ key: 'calledNumber', header: 'Número', render: (r) => r.calledNumber },
|
||||||
|
{ key: 'lead', header: 'Lead', render: (r) => r.lead?.name ?? '—' },
|
||||||
|
{ key: 'campaign', header: 'Campanha', render: (r) => r.campaign?.name ?? '—' },
|
||||||
|
{ key: 'state', header: 'Estado', render: (r) => <Badge variant="secondary">{r.state}</Badge> },
|
||||||
|
{ key: 'hangupCause', header: 'Causa', render: (r) => r.hangupCause ?? '—' },
|
||||||
|
{ key: 'disposition', header: 'Disposição', render: (r) => r.disposition?.name ?? '—' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Relatório de Chamadas"
|
||||||
|
description="Pesquisa e exportação de tentativas de chamada"
|
||||||
|
actions={
|
||||||
|
can('reports.export') && (
|
||||||
|
<Button variant="outline" loading={exportMutation.isPending} onClick={() => exportMutation.mutate()}>
|
||||||
|
<Download /> Exportar CSV
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4 grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<MetricCard label="TME médio" value={formatSeconds(metrics?.tmeSeconds)} />
|
||||||
|
<MetricCard label="TMA médio" value={formatSeconds(metrics?.tmaSeconds)} />
|
||||||
|
<MetricCard label="Taxa de atendimento" value={formatPercent(metrics?.answerRate)} />
|
||||||
|
<MetricCard label="Taxa de abandono" value={formatPercent(metrics?.abandonRate)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardContent className="grid grid-cols-2 gap-3 pt-5 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Campanha</Label>
|
||||||
|
<Select value={filters.campaignId} onValueChange={(v) => setFilters((f) => ({ ...f, campaignId: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todas</SelectItem>
|
||||||
|
{(campaigns ?? []).map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Fila</Label>
|
||||||
|
<Select value={filters.queueId} onValueChange={(v) => setFilters((f) => ({ ...f, queueId: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todas</SelectItem>
|
||||||
|
{(queues ?? []).map((q) => (
|
||||||
|
<SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Agente</Label>
|
||||||
|
<Select value={filters.agentId} onValueChange={(v) => setFilters((f) => ({ ...f, agentId: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
|
{(agents ?? []).map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Estado</Label>
|
||||||
|
<Select value={filters.state} onValueChange={(v) => setFilters((f) => ({ ...f, state: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
|
{STATES.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Telefone</Label>
|
||||||
|
<Input value={filters.phone} onChange={(e) => setFilters((f) => ({ ...f, phone: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Button onClick={() => { setPage(1); refetch(); }}>
|
||||||
|
<Search className="size-4" /> Buscar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>De</Label>
|
||||||
|
<Input type="datetime-local" value={filters.from} onChange={(e) => setFilters((f) => ({ ...f, from: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Até</Label>
|
||||||
|
<Input type="datetime-local" value={filters.to} onChange={(e) => setFilters((f) => ({ ...f, to: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data?.items}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
page={data?.page ?? page}
|
||||||
|
pageSize={data?.pageSize ?? 25}
|
||||||
|
total={data?.total ?? 0}
|
||||||
|
onPageChange={setPage}
|
||||||
|
emptyMessage="Nenhuma chamada encontrada para os filtros selecionados."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CallsReportPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="reports.view">
|
||||||
|
<Content />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
331
apps/frontend/src/app/(app)/troncos/page.tsx
Normal file
331
apps/frontend/src/app/(app)/troncos/page.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Trash2, Pencil, Activity } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { useDebounce } from '@/hooks/use-debounce';
|
||||||
|
import { trunksService, type TrunkInput } from '@/services/trunks';
|
||||||
|
import type { Trunk } from '@/types';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
|
||||||
|
const EMPTY_FORM: TrunkInput = {
|
||||||
|
name: '',
|
||||||
|
type: 'IP',
|
||||||
|
host: '',
|
||||||
|
port: 5060,
|
||||||
|
maxCps: 5,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function TrunkFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
trunk,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
trunk: Trunk | null;
|
||||||
|
}) {
|
||||||
|
const isEdit = Boolean(trunk);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [form, setForm] = React.useState<TrunkInput>(EMPTY_FORM);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setForm(
|
||||||
|
trunk
|
||||||
|
? {
|
||||||
|
host: trunk.host,
|
||||||
|
port: trunk.port,
|
||||||
|
username: trunk.username ?? '',
|
||||||
|
callerId: trunk.callerId ?? '',
|
||||||
|
context: trunk.context,
|
||||||
|
maxCps: trunk.maxCps,
|
||||||
|
enabled: trunk.enabled,
|
||||||
|
}
|
||||||
|
: EMPTY_FORM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [open, trunk]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
isEdit && trunk ? trunksService.update(trunk.id, form) : trunksService.create(form),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['trunks'] });
|
||||||
|
toast({ title: 'Tronco salvo', variant: 'success' });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Editar tronco' : 'Novo tronco'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
{!isEdit && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="name">Nome</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
required
|
||||||
|
value={form.name ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Tipo</Label>
|
||||||
|
<Select
|
||||||
|
value={form.type}
|
||||||
|
onValueChange={(v) => setForm((f) => ({ ...f, type: v as TrunkInput['type'] }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="IP">IP</SelectItem>
|
||||||
|
<SelectItem value="REGISTRATION">Registration</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="host">Host</Label>
|
||||||
|
<Input
|
||||||
|
id="host"
|
||||||
|
required
|
||||||
|
value={form.host}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="port">Porta</Label>
|
||||||
|
<Input
|
||||||
|
id="port"
|
||||||
|
type="number"
|
||||||
|
value={form.port ?? 5060}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, port: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="username">Usuário</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={form.username ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="password">Senha {isEdit && '(deixe em branco para manter)'}</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="callerId">Caller ID</Label>
|
||||||
|
<Input
|
||||||
|
id="callerId"
|
||||||
|
value={form.callerId ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, callerId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="maxCps">CPS máximo</Label>
|
||||||
|
<Input
|
||||||
|
id="maxCps"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
required
|
||||||
|
value={form.maxCps}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, maxCps: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" loading={mutation.isPending}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrunksContent() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [search, setSearch] = React.useState('');
|
||||||
|
const debouncedSearch = useDebounce(search);
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [editing, setEditing] = React.useState<Trunk | null>(null);
|
||||||
|
const [statusById, setStatusById] = React.useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['trunks'],
|
||||||
|
queryFn: trunksService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => trunksService.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['trunks'] });
|
||||||
|
toast({ title: 'Tronco removido', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkStatus = useMutation({
|
||||||
|
mutationFn: (id: string) => trunksService.status(id),
|
||||||
|
onSuccess: (result, id) =>
|
||||||
|
setStatusById((prev) => ({ ...prev, [id]: result.status })),
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = (data ?? []).filter((t) =>
|
||||||
|
t.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||||
|
t.host.includes(debouncedSearch),
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: DataTableColumn<Trunk>[] = [
|
||||||
|
{ key: 'name', header: 'Nome', render: (r) => r.name },
|
||||||
|
{ key: 'type', header: 'Tipo', render: (r) => r.type },
|
||||||
|
{ key: 'host', header: 'Host', render: (r) => `${r.host}:${r.port}` },
|
||||||
|
{ key: 'maxCps', header: 'CPS máx.', render: (r) => r.maxCps },
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
render: (r) =>
|
||||||
|
statusById[r.id] ? (
|
||||||
|
<Badge variant={statusById[r.id] === 'ok' ? 'success' : 'destructive'}>
|
||||||
|
{statusById[r.id]}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant={r.enabled ? 'secondary' : 'outline'}>
|
||||||
|
{r.enabled ? 'Ativo' : 'Inativo'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => checkStatus.mutate(r.id)}>
|
||||||
|
<Activity className="size-4" />
|
||||||
|
</Button>
|
||||||
|
{can('trunks.update') && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(r);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{can('trunks.delete') && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Remover o tronco ${r.name}?`)) remove.mutate(r.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Troncos"
|
||||||
|
description="Troncos SIP de saída para operadoras/gateways"
|
||||||
|
actions={
|
||||||
|
can('trunks.create') && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus /> Novo tronco
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={filtered}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
searchValue={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Buscar por nome ou host..."
|
||||||
|
emptyMessage="Nenhum tronco cadastrado."
|
||||||
|
/>
|
||||||
|
<TrunkFormDialog open={dialogOpen} onOpenChange={setDialogOpen} trunk={editing} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrunksPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="trunks.view">
|
||||||
|
<TrunksContent />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
apps/frontend/src/app/(app)/usuarios/page.tsx
Normal file
222
apps/frontend/src/app/(app)/usuarios/page.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Pencil } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { usersService, type CreateUserInput, type UpdateUserInput } from '@/services/users';
|
||||||
|
import { rolesService } from '@/services/roles';
|
||||||
|
import type { UserRecord } from '@/types';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
function UserFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
user: UserRecord | null;
|
||||||
|
}) {
|
||||||
|
const isEdit = Boolean(user);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [name, setName] = React.useState('');
|
||||||
|
const [email, setEmail] = React.useState('');
|
||||||
|
const [isActive, setIsActive] = React.useState(true);
|
||||||
|
const [roleIds, setRoleIds] = React.useState<string[]>([]);
|
||||||
|
|
||||||
|
const { data: roles } = useQuery({ queryKey: ['roles'], queryFn: rolesService.list, enabled: open });
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(user?.name ?? '');
|
||||||
|
setEmail(user?.email ?? '');
|
||||||
|
setIsActive(user?.isActive ?? true);
|
||||||
|
setRoleIds(user?.roles.map((r) => r.id) ?? []);
|
||||||
|
}
|
||||||
|
}, [open, user]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (isEdit && user) {
|
||||||
|
const input: UpdateUserInput = { name, isActive, roleIds };
|
||||||
|
return usersService.update(user.id, input);
|
||||||
|
}
|
||||||
|
const input: CreateUserInput = { name, email, roleIds };
|
||||||
|
return usersService.create(input);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
toast({ title: 'Usuário salvo', variant: 'success' });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Editar usuário' : 'Novo usuário'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="name">Nome</Label>
|
||||||
|
<Input id="name" required value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{!isEdit && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="email">E-mail</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isEdit && (
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
|
||||||
|
<Label htmlFor="isActive">Usuário ativo</Label>
|
||||||
|
<Switch id="isActive" checked={isActive} onCheckedChange={setIsActive} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Perfis</Label>
|
||||||
|
<div className="flex flex-col gap-2 rounded-md border border-border p-3">
|
||||||
|
{(roles ?? []).map((r) => (
|
||||||
|
<label key={r.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={roleIds.includes(r.id)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setRoleIds((prev) =>
|
||||||
|
checked ? [...prev, r.id] : prev.filter((id) => id !== r.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{r.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" loading={mutation.isPending} disabled={roleIds.length === 0}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Content() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [editing, setEditing] = React.useState<UserRecord | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: usersService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<UserRecord>[] = [
|
||||||
|
{ key: 'name', header: 'Nome', render: (r) => r.name },
|
||||||
|
{ key: 'email', header: 'E-mail', render: (r) => r.email },
|
||||||
|
{
|
||||||
|
key: 'roles',
|
||||||
|
header: 'Perfis',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{r.roles.map((role) => (
|
||||||
|
<Badge key={role.id} variant="secondary">{role.name}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isActive',
|
||||||
|
header: 'Status',
|
||||||
|
render: (r) => <Badge variant={r.isActive ? 'success' : 'secondary'}>{r.isActive ? 'Ativo' : 'Inativo'}</Badge>,
|
||||||
|
},
|
||||||
|
{ key: 'lastLoginAt', header: 'Último login', render: (r) => formatDateTime(r.lastLoginAt) },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
render: (r) =>
|
||||||
|
can('users.update') ? (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(r);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Usuários"
|
||||||
|
description="Contas de acesso ao sistema"
|
||||||
|
actions={
|
||||||
|
can('users.create') && (
|
||||||
|
<Button onClick={() => { setEditing(null); setDialogOpen(true); }}>
|
||||||
|
<Plus /> Novo usuário
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
emptyMessage="Nenhum usuário cadastrado."
|
||||||
|
/>
|
||||||
|
<UserFormDialog open={dialogOpen} onOpenChange={setDialogOpen} user={editing} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
return (
|
||||||
|
<RequirePermission permission="users.view">
|
||||||
|
<Content />
|
||||||
|
</RequirePermission>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
apps/frontend/src/app/globals.css
Normal file
126
apps/frontend/src/app/globals.css
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(0.99 0.002 260);
|
||||||
|
--foreground: oklch(0.22 0.02 260);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.22 0.02 260);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.22 0.02 260);
|
||||||
|
--primary: oklch(0.52 0.19 265);
|
||||||
|
--primary-foreground: oklch(0.98 0.005 260);
|
||||||
|
--secondary: oklch(0.96 0.01 260);
|
||||||
|
--secondary-foreground: oklch(0.28 0.02 260);
|
||||||
|
--muted: oklch(0.96 0.01 260);
|
||||||
|
--muted-foreground: oklch(0.52 0.02 260);
|
||||||
|
--accent: oklch(0.94 0.03 265);
|
||||||
|
--accent-foreground: oklch(0.28 0.05 265);
|
||||||
|
--destructive: oklch(0.58 0.22 27);
|
||||||
|
--destructive-foreground: oklch(0.98 0.005 260);
|
||||||
|
--success: oklch(0.62 0.17 148);
|
||||||
|
--success-foreground: oklch(0.98 0.005 260);
|
||||||
|
--warning: oklch(0.75 0.16 75);
|
||||||
|
--warning-foreground: oklch(0.22 0.02 260);
|
||||||
|
--border: oklch(0.9 0.01 260);
|
||||||
|
--input: oklch(0.9 0.01 260);
|
||||||
|
--ring: oklch(0.52 0.19 265);
|
||||||
|
--sidebar: oklch(0.18 0.02 260);
|
||||||
|
--sidebar-foreground: oklch(0.9 0.01 260);
|
||||||
|
--sidebar-muted: oklch(0.65 0.02 260);
|
||||||
|
--sidebar-accent: oklch(0.27 0.03 260);
|
||||||
|
--sidebar-border: oklch(0.27 0.02 260);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.16 0.015 260);
|
||||||
|
--foreground: oklch(0.93 0.01 260);
|
||||||
|
--card: oklch(0.2 0.015 260);
|
||||||
|
--card-foreground: oklch(0.93 0.01 260);
|
||||||
|
--popover: oklch(0.2 0.015 260);
|
||||||
|
--popover-foreground: oklch(0.93 0.01 260);
|
||||||
|
--primary: oklch(0.68 0.17 265);
|
||||||
|
--primary-foreground: oklch(0.16 0.02 260);
|
||||||
|
--secondary: oklch(0.26 0.015 260);
|
||||||
|
--secondary-foreground: oklch(0.9 0.01 260);
|
||||||
|
--muted: oklch(0.26 0.015 260);
|
||||||
|
--muted-foreground: oklch(0.65 0.02 260);
|
||||||
|
--accent: oklch(0.3 0.04 265);
|
||||||
|
--accent-foreground: oklch(0.9 0.03 265);
|
||||||
|
--destructive: oklch(0.62 0.2 27);
|
||||||
|
--destructive-foreground: oklch(0.98 0.005 260);
|
||||||
|
--success: oklch(0.68 0.16 148);
|
||||||
|
--success-foreground: oklch(0.16 0.02 260);
|
||||||
|
--warning: oklch(0.78 0.15 75);
|
||||||
|
--warning-foreground: oklch(0.16 0.02 260);
|
||||||
|
--border: oklch(0.3 0.015 260);
|
||||||
|
--input: oklch(0.3 0.015 260);
|
||||||
|
--ring: oklch(0.68 0.17 265);
|
||||||
|
--sidebar: oklch(0.12 0.015 260);
|
||||||
|
--sidebar-foreground: oklch(0.88 0.01 260);
|
||||||
|
--sidebar-muted: oklch(0.58 0.02 260);
|
||||||
|
--sidebar-accent: oklch(0.22 0.02 260);
|
||||||
|
--sidebar-border: oklch(0.24 0.015 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-success-foreground: var(--success-foreground);
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-muted: var(--sidebar-muted);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--radius-sm: calc(var(--radius) - 0.25rem);
|
||||||
|
--radius-md: calc(var(--radius) - 0.125rem);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 0.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
BIN
apps/frontend/src/app/icon.png
Normal file
BIN
apps/frontend/src/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 766 KiB |
22
apps/frontend/src/app/layout.tsx
Normal file
22
apps/frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Providers } from '@/components/providers';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'B2BCall',
|
||||||
|
description: 'Plataforma de discador preditivo e call center B2BCall',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="pt-BR" suppressHydrationWarning>
|
||||||
|
<body className="antialiased">
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
apps/frontend/src/app/login/page.tsx
Normal file
91
apps/frontend/src/app/login/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { authService } from '@/services/auth';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<React.Suspense fallback={null}>
|
||||||
|
<LoginForm />
|
||||||
|
</React.Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useSearchParams();
|
||||||
|
const [email, setEmail] = React.useState('');
|
||||||
|
const [password, setPassword] = React.useState('');
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await authService.login(email, password);
|
||||||
|
if (result.mustChangePassword) {
|
||||||
|
router.push('/trocar-senha');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = params.get('next') || '/';
|
||||||
|
window.location.href = next;
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-secondary/40 px-4">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader className="items-center text-center">
|
||||||
|
<Image src="/logo.png" alt="B2BCall" width={64} height={64} className="mb-2 rounded-xl" />
|
||||||
|
<CardTitle className="text-lg">B2BCall</CardTitle>
|
||||||
|
<CardDescription>Entre com suas credenciais para continuar</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="email">E-mail</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="password">Senha</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<Button type="submit" disabled={loading} className="mt-1">
|
||||||
|
{loading && <Loader2 className="animate-spin" />}
|
||||||
|
Entrar
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
apps/frontend/src/app/trocar-senha/page.tsx
Normal file
92
apps/frontend/src/app/trocar-senha/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { authService } from '@/services/auth';
|
||||||
|
import { errorMessage } from '@/lib/error-message';
|
||||||
|
|
||||||
|
export default function ChangePasswordPage() {
|
||||||
|
const [current, setCurrent] = React.useState('');
|
||||||
|
const [next, setNext] = React.useState('');
|
||||||
|
const [confirm, setConfirm] = React.useState('');
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (next !== confirm) {
|
||||||
|
setError('As senhas não coincidem.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await authService.changePassword(current, next);
|
||||||
|
window.location.href = '/';
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-secondary/40 px-4">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader className="items-center text-center">
|
||||||
|
<Image src="/logo.png" alt="B2BCall" width={64} height={64} className="mb-2 rounded-xl" />
|
||||||
|
<CardTitle className="text-lg">Troca de senha obrigatória</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Defina uma nova senha para continuar utilizando o sistema.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="current">Senha atual</Label>
|
||||||
|
<Input
|
||||||
|
id="current"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="next">Nova senha</Label>
|
||||||
|
<Input
|
||||||
|
id="next"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={next}
|
||||||
|
onChange={(e) => setNext(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="confirm">Confirmar nova senha</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<Button type="submit" disabled={loading} className="mt-1">
|
||||||
|
{loading && <Loader2 className="animate-spin" />}
|
||||||
|
Salvar nova senha
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
apps/frontend/src/components/campaign-select.tsx
Normal file
36
apps/frontend/src/components/campaign-select.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { campaignsService } from '@/services/campaigns';
|
||||||
|
|
||||||
|
export function CampaignSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { data } = useQuery({ queryKey: ['campaigns'], queryFn: campaignsService.list });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={onChange}>
|
||||||
|
<SelectTrigger className="w-64">
|
||||||
|
<SelectValue placeholder="Selecione uma campanha..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(data ?? []).map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
apps/frontend/src/components/data-table/data-table.tsx
Normal file
181
apps/frontend/src/components/data-table/data-table.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { AlertTriangle, Inbox, Search } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableCell,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface DataTableColumn<T> {
|
||||||
|
key: string;
|
||||||
|
header: string;
|
||||||
|
render: (row: T) => React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTableProps<T> {
|
||||||
|
columns: DataTableColumn<T>[];
|
||||||
|
data: T[] | undefined;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
rowKey: (row: T) => string;
|
||||||
|
searchValue?: string;
|
||||||
|
onSearchChange?: (value: string) => void;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
toolbar?: React.ReactNode;
|
||||||
|
emptyMessage?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
total?: number;
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<T>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
errorMessage = 'Não foi possível carregar os dados.',
|
||||||
|
onRetry,
|
||||||
|
rowKey,
|
||||||
|
searchValue,
|
||||||
|
onSearchChange,
|
||||||
|
searchPlaceholder = 'Buscar...',
|
||||||
|
toolbar,
|
||||||
|
emptyMessage = 'Nenhum registro encontrado.',
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
onPageChange,
|
||||||
|
onRowClick,
|
||||||
|
}: DataTableProps<T>) {
|
||||||
|
const showPagination =
|
||||||
|
page !== undefined && pageSize !== undefined && total !== undefined;
|
||||||
|
const totalPages = showPagination ? Math.max(1, Math.ceil(total / pageSize)) : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{(onSearchChange || toolbar) && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{onSearchChange && (
|
||||||
|
<div className="relative w-full max-w-xs">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={searchValue ?? ''}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-2">{toolbar}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<TableHead key={col.key} className={col.className}>
|
||||||
|
{col.header}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading &&
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<TableRow key={`skeleton-${i}`}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<TableCell key={col.key}>
|
||||||
|
<Skeleton className="h-4 w-full max-w-32" />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!isLoading && isError && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="py-10">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center text-muted-foreground">
|
||||||
|
<AlertTriangle className="size-6 text-destructive" />
|
||||||
|
<p className="text-sm">{errorMessage}</p>
|
||||||
|
{onRetry && (
|
||||||
|
<Button size="sm" variant="outline" onClick={onRetry}>
|
||||||
|
Tentar novamente
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && (!data || data.length === 0) && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="py-10">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center text-muted-foreground">
|
||||||
|
<Inbox className="size-6" />
|
||||||
|
<p className="text-sm">{emptyMessage}</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading &&
|
||||||
|
!isError &&
|
||||||
|
data?.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={rowKey(row)}
|
||||||
|
onClick={() => onRowClick?.(row)}
|
||||||
|
className={cn(onRowClick && 'cursor-pointer')}
|
||||||
|
>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<TableCell key={col.key} className={col.className}>
|
||||||
|
{col.render(row)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{showPagination && total > 0 && (
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{total} registro{total === 1 ? '' : 's'} — página {page} de {totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => onPageChange?.(page - 1)}
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => onPageChange?.(page + 1)}
|
||||||
|
>
|
||||||
|
Próxima
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
apps/frontend/src/components/layout/app-shell.tsx
Normal file
50
apps/frontend/src/components/layout/app-shell.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { Sidebar } from './sidebar';
|
||||||
|
import { Topbar } from './topbar';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isLoading, isAuthenticated } = useAuth();
|
||||||
|
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isLoading && !isAuthenticated) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}, [isLoading, isAuthenticated]);
|
||||||
|
|
||||||
|
if (isLoading || !isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-background">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mobileOpen && (
|
||||||
|
<div className="fixed inset-0 z-40 lg:hidden">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
<Sidebar className={cn('absolute inset-y-0 left-0 shadow-xl')} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<Topbar onOpenSidebar={() => setMobileOpen(true)} />
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
apps/frontend/src/components/layout/nav-config.ts
Normal file
94
apps/frontend/src/components/layout/nav-config.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type { Permission } from '@/lib/permissions';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
PhoneOutgoing,
|
||||||
|
Users2,
|
||||||
|
Radio,
|
||||||
|
Activity,
|
||||||
|
BarChart3,
|
||||||
|
ShieldCheck,
|
||||||
|
Headset,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export interface NavLeaf {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
permission?: Permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavGroup {
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
items: NavLeaf[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NAV_SECTIONS: (NavLeaf | NavGroup)[] = [
|
||||||
|
{ label: 'Dashboard', href: '/', permission: 'dashboard.view' },
|
||||||
|
{
|
||||||
|
label: 'Discador',
|
||||||
|
icon: PhoneOutgoing,
|
||||||
|
items: [
|
||||||
|
{ label: 'Campanhas', href: '/campanhas', permission: 'campaigns.view' },
|
||||||
|
{ label: 'Leads', href: '/leads', permission: 'campaigns.view' },
|
||||||
|
{ label: 'Importações', href: '/importacoes', permission: 'campaigns.view' },
|
||||||
|
{ label: 'Lista de Bloqueio', href: '/bloqueio', permission: 'campaigns.view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Call Center',
|
||||||
|
icon: Headset,
|
||||||
|
items: [
|
||||||
|
{ label: 'Agentes', href: '/agentes', permission: 'agents.view' },
|
||||||
|
{ label: 'Filas', href: '/filas', permission: 'queues.view' },
|
||||||
|
{ label: 'Motivos de Pausa', href: '/pausas', permission: 'settings.manage' },
|
||||||
|
{ label: 'Disposições', href: '/disposicoes', permission: 'settings.manage' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Telefonia',
|
||||||
|
icon: Radio,
|
||||||
|
items: [
|
||||||
|
{ label: 'Ramais', href: '/ramais', permission: 'extensions.view' },
|
||||||
|
{ label: 'Troncos', href: '/troncos', permission: 'trunks.view' },
|
||||||
|
{ label: 'Dialplan', href: '/dialplan', permission: 'dialplans.view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Monitoramento',
|
||||||
|
icon: Activity,
|
||||||
|
items: [
|
||||||
|
{ label: 'Filas', href: '/monitoramento/filas', permission: 'monitoring.view' },
|
||||||
|
{ label: 'Agentes', href: '/monitoramento/agentes', permission: 'monitoring.view' },
|
||||||
|
{ label: 'Ramais', href: '/monitoramento/ramais', permission: 'monitoring.view' },
|
||||||
|
{ label: 'Campanhas', href: '/monitoramento/campanhas', permission: 'monitoring.view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Relatórios',
|
||||||
|
icon: BarChart3,
|
||||||
|
items: [
|
||||||
|
{ label: 'Chamadas', href: '/relatorios/chamadas', permission: 'reports.view' },
|
||||||
|
{ label: 'Agentes', href: '/relatorios/agentes', permission: 'reports.view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sistema',
|
||||||
|
icon: ShieldCheck,
|
||||||
|
items: [
|
||||||
|
{ label: 'Usuários', href: '/usuarios', permission: 'users.view' },
|
||||||
|
{ label: 'Perfis e Permissões', href: '/perfis', permission: 'roles.manage' },
|
||||||
|
{ label: 'Asterisk', href: '/asterisk', permission: 'asterisk.view' },
|
||||||
|
{ label: 'Compliance', href: '/compliance', permission: 'settings.manage' },
|
||||||
|
{ label: 'Auditoria', href: '/auditoria', permission: 'audit.view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ label: 'Console do Agente', href: '/agente' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isNavGroup(item: NavLeaf | NavGroup): item is NavGroup {
|
||||||
|
return 'items' in item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dashboardIcon = LayoutDashboard;
|
||||||
|
export const usersIcon = Users2;
|
||||||
19
apps/frontend/src/components/layout/page-header.tsx
Normal file
19
apps/frontend/src/components/layout/page-header.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
||||||
|
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
apps/frontend/src/components/layout/sidebar.tsx
Normal file
114
apps/frontend/src/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { ChevronDown, Headset } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { NAV_SECTIONS, isNavGroup, dashboardIcon as DashboardIcon } from './nav-config';
|
||||||
|
|
||||||
|
export function Sidebar({ className }: { className?: string }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { can } = useAuth();
|
||||||
|
const [openGroups, setOpenGroups] = React.useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const initial: Record<string, boolean> = {};
|
||||||
|
for (const section of NAV_SECTIONS) {
|
||||||
|
if (isNavGroup(section)) {
|
||||||
|
initial[section.label] = section.items.some((item) =>
|
||||||
|
item.href === '/' ? pathname === '/' : pathname.startsWith(item.href),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOpenGroups((prev) => ({ ...initial, ...prev }));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-64 flex-col bg-sidebar text-sidebar-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 px-5 py-5">
|
||||||
|
<Image src="/logo.png" alt="B2BCall" width={32} height={32} className="rounded-lg" />
|
||||||
|
<span className="text-base font-semibold tracking-tight text-white">B2BCall</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto px-3 pb-4">
|
||||||
|
<ul className="flex flex-col gap-0.5">
|
||||||
|
{NAV_SECTIONS.filter((s) => (isNavGroup(s) ? true : !s.permission || can(s.permission)))
|
||||||
|
.map((section) => {
|
||||||
|
if (!isNavGroup(section)) {
|
||||||
|
const active = section.href === '/' ? pathname === '/' : pathname.startsWith(section.href);
|
||||||
|
const Icon = section.href === '/agente' ? Headset : DashboardIcon;
|
||||||
|
return (
|
||||||
|
<li key={section.href}>
|
||||||
|
<Link
|
||||||
|
href={section.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium text-sidebar-muted transition-colors hover:bg-sidebar-accent hover:text-white',
|
||||||
|
active && 'bg-sidebar-accent text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4 shrink-0" />
|
||||||
|
{section.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleItems = section.items.filter(
|
||||||
|
(item) => !item.permission || can(item.permission),
|
||||||
|
);
|
||||||
|
if (visibleItems.length === 0) return null;
|
||||||
|
const isOpen = openGroups[section.label] ?? false;
|
||||||
|
const GroupIcon = section.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={section.label} className="mt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setOpenGroups((prev) => ({ ...prev, [section.label]: !isOpen }))
|
||||||
|
}
|
||||||
|
className="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm font-medium text-sidebar-muted hover:bg-sidebar-accent hover:text-white"
|
||||||
|
>
|
||||||
|
<GroupIcon className="size-4 shrink-0" />
|
||||||
|
<span className="flex-1">{section.label}</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn('size-3.5 transition-transform', isOpen && 'rotate-180')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<ul className="mt-0.5 flex flex-col gap-0.5 border-l border-sidebar-border pl-4">
|
||||||
|
{visibleItems.map((item) => {
|
||||||
|
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
'block rounded-md px-3 py-1.5 text-sm text-sidebar-muted transition-colors hover:bg-sidebar-accent hover:text-white',
|
||||||
|
active && 'bg-sidebar-accent text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/frontend/src/components/layout/topbar.tsx
Normal file
60
apps/frontend/src/components/layout/topbar.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Moon, Sun, LogOut, UserRound, Menu } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { useAuth, useLogout } from '@/hooks/use-auth';
|
||||||
|
import { useTheme } from '@/hooks/use-theme';
|
||||||
|
|
||||||
|
export function Topbar({ onOpenSidebar }: { onOpenSidebar?: () => void }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { theme, toggle } = useTheme();
|
||||||
|
const logout = useLogout();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center gap-3 border-b border-border bg-card px-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="lg:hidden"
|
||||||
|
onClick={onOpenSidebar}
|
||||||
|
aria-label="Abrir menu"
|
||||||
|
>
|
||||||
|
<Menu className="size-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<Button variant="ghost" size="icon" onClick={toggle} aria-label="Alternar tema">
|
||||||
|
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="gap-2 px-2">
|
||||||
|
<span className="flex size-7 items-center justify-center rounded-full bg-primary/15 text-primary">
|
||||||
|
<UserRound className="size-4" />
|
||||||
|
</span>
|
||||||
|
<span className="hidden text-sm font-medium sm:inline">{user?.email}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>{user?.email}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => logout()} className="text-destructive">
|
||||||
|
<LogOut />
|
||||||
|
Sair
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
apps/frontend/src/components/providers.tsx
Normal file
35
apps/frontend/src/components/providers.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { ToastProvider } from '@/components/ui/toast';
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
|
import { AuthProvider } from '@/hooks/use-auth';
|
||||||
|
import { ThemeProvider } from '@/hooks/use-theme';
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
const [queryClient] = React.useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 15_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</TooltipProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/frontend/src/components/require-permission.tsx
Normal file
24
apps/frontend/src/components/require-permission.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ShieldAlert } from 'lucide-react';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import type { Permission } from '@/lib/permissions';
|
||||||
|
|
||||||
|
export function RequirePermission({
|
||||||
|
permission,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
permission: Permission | Permission[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { can } = useAuth();
|
||||||
|
if (!can(permission)) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-border py-24 text-center text-muted-foreground">
|
||||||
|
<ShieldAlert className="size-8" />
|
||||||
|
<p className="text-sm">Você não tem permissão para acessar esta tela.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
30
apps/frontend/src/components/ui/badge.tsx
Normal file
30
apps/frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary/15 text-primary',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||||
|
success: 'border-transparent bg-success/15 text-success',
|
||||||
|
warning: 'border-transparent bg-warning/20 text-warning',
|
||||||
|
destructive: 'border-transparent bg-destructive/15 text-destructive',
|
||||||
|
outline: 'border-border text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
62
apps/frontend/src/components/ui/button.tsx
Normal file
62
apps/frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:opacity-90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground hover:opacity-90',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:opacity-80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild, loading, children, disabled, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="animate-spin" />}
|
||||||
|
{children}
|
||||||
|
</Comp>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
66
apps/frontend/src/components/ui/card.tsx
Normal file
66
apps/frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border border-border bg-card text-card-foreground shadow-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col gap-1 p-5', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm font-semibold leading-none', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<'div'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-5 pt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||||
27
apps/frontend/src/components/ui/checkbox.tsx
Normal file
27
apps/frontend/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'peer size-4 shrink-0 rounded-sm border border-input shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
|
||||||
|
<Check className="size-3" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
));
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
92
apps/frontend/src/components/ui/dialog.tsx
Normal file
92
apps/frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn('fixed inset-0 z-50 bg-black/50', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-border bg-card p-6 shadow-lg max-h-[90vh] overflow-y-auto',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
|
||||||
|
<X className="size-4" />
|
||||||
|
<span className="sr-only">Fechar</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div className={cn('flex flex-col gap-1.5', className)} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.ComponentProps<'div'>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-base font-semibold leading-none', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
91
apps/frontend/src/components/ui/dropdown-menu.tsx
Normal file
91
apps/frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-40 overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1.5 text-xs font-semibold text-muted-foreground',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
||||||
34
apps/frontend/src/components/ui/input.tsx
Normal file
34
apps/frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||||
|
({ className, type, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.ComponentProps<'textarea'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-16 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
|
export { Input, Textarea };
|
||||||
22
apps/frontend/src/components/ui/label.tsx
Normal file
22
apps/frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
77
apps/frontend/src/components/ui/select.tsx
Normal file
77
apps/frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
|
import { Check, ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
position={position}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md',
|
||||||
|
position === 'popper' && 'translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport className="p-1">
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem };
|
||||||
12
apps/frontend/src/components/ui/skeleton.tsx
Normal file
12
apps/frontend/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('animate-pulse rounded-md bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
28
apps/frontend/src/components/ui/switch.tsx
Normal file
28
apps/frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none block size-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
66
apps/frontend/src/components/ui/table.tsx
Normal file
66
apps/frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Table = React.forwardRef<HTMLTableElement, React.ComponentProps<'table'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div className="w-full overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Table.displayName = 'Table';
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.ComponentProps<'thead'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn('bg-muted/60 [&_tr]:border-b', className)} {...props} />
|
||||||
|
));
|
||||||
|
TableHeader.displayName = 'TableHeader';
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.ComponentProps<'tbody'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||||
|
));
|
||||||
|
TableBody.displayName = 'TableBody';
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<HTMLTableRowElement, React.ComponentProps<'tr'>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-b border-border transition-colors hover:bg-muted/40 data-[state=selected]:bg-muted',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
TableRow.displayName = 'TableRow';
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ComponentProps<'th'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-10 whitespace-nowrap px-3 text-left align-middle text-xs font-medium uppercase tracking-wide text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHead.displayName = 'TableHead';
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ComponentProps<'td'>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td ref={ref} className={cn('px-3 py-2.5 align-middle', className)} {...props} />
|
||||||
|
));
|
||||||
|
TableCell.displayName = 'TableCell';
|
||||||
|
|
||||||
|
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
||||||
51
apps/frontend/src/components/ui/tabs.tsx
Normal file
51
apps/frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-9 items-center gap-1 rounded-lg bg-muted p-1 text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn('mt-4 focus-visible:outline-none', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
90
apps/frontend/src/components/ui/toast.tsx
Normal file
90
apps/frontend/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as ToastPrimitive from '@radix-ui/react-toast';
|
||||||
|
import { CheckCircle2, XCircle, Info, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface ToastMessage {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
variant?: 'default' | 'success' | 'destructive';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
toast: (message: Omit<ToastMessage, 'id'>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = React.createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const ctx = React.useContext(ToastContext);
|
||||||
|
if (!ctx) throw new Error('useToast deve ser usado dentro de ToastProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
default: Info,
|
||||||
|
success: CheckCircle2,
|
||||||
|
destructive: XCircle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [messages, setMessages] = React.useState<ToastMessage[]>([]);
|
||||||
|
const idRef = React.useRef(0);
|
||||||
|
|
||||||
|
const toast = React.useCallback((message: Omit<ToastMessage, 'id'>) => {
|
||||||
|
idRef.current += 1;
|
||||||
|
const id = idRef.current;
|
||||||
|
setMessages((prev) => [...prev, { ...message, id }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const remove = (id: number) =>
|
||||||
|
setMessages((prev) => prev.filter((m) => m.id !== id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toast }}>
|
||||||
|
<ToastPrimitive.Provider swipeDirection="right" duration={5000}>
|
||||||
|
{children}
|
||||||
|
{messages.map((m) => {
|
||||||
|
const Icon = icons[m.variant ?? 'default'];
|
||||||
|
return (
|
||||||
|
<ToastPrimitive.Root
|
||||||
|
key={m.id}
|
||||||
|
onOpenChange={(open) => !open && remove(m.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 rounded-lg border p-4 shadow-lg data-[state=open]:animate-none',
|
||||||
|
'bg-card text-card-foreground border-border',
|
||||||
|
m.variant === 'destructive' && 'border-destructive/40 bg-destructive/10',
|
||||||
|
m.variant === 'success' && 'border-success/40 bg-success/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
'mt-0.5 size-4 shrink-0',
|
||||||
|
m.variant === 'destructive' && 'text-destructive',
|
||||||
|
m.variant === 'success' && 'text-success',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-0.5">
|
||||||
|
<ToastPrimitive.Title className="text-sm font-medium">
|
||||||
|
{m.title}
|
||||||
|
</ToastPrimitive.Title>
|
||||||
|
{m.description && (
|
||||||
|
<ToastPrimitive.Description className="text-xs text-muted-foreground">
|
||||||
|
{m.description}
|
||||||
|
</ToastPrimitive.Description>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ToastPrimitive.Close className="ml-auto">
|
||||||
|
<X className="size-3.5 text-muted-foreground" />
|
||||||
|
</ToastPrimitive.Close>
|
||||||
|
</ToastPrimitive.Root>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ToastPrimitive.Viewport className="fixed bottom-0 right-0 z-[100] flex w-full max-w-sm flex-col gap-2 p-4 outline-none" />
|
||||||
|
</ToastPrimitive.Provider>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
apps/frontend/src/components/ui/tooltip.tsx
Normal file
27
apps/frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background shadow-md',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
62
apps/frontend/src/hooks/use-auth.tsx
Normal file
62
apps/frontend/src/hooks/use-auth.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { authService } from '@/services/auth';
|
||||||
|
import { ApiError } from '@/lib/api-client';
|
||||||
|
import type { CurrentUser } from '@/types';
|
||||||
|
import type { Permission } from '@/lib/permissions';
|
||||||
|
import { hasPermission, hasAnyPermission } from '@/lib/permissions';
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: CurrentUser | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
can: (permission: Permission | Permission[]) => boolean;
|
||||||
|
canAny: (permissions: Permission[]) => boolean;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = React.createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['auth', 'me'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return await authService.me();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
retry: false,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const value: AuthContextValue = {
|
||||||
|
user: data ?? undefined,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated: Boolean(data),
|
||||||
|
can: (permission) => hasPermission(data?.permissions, permission),
|
||||||
|
canAny: (permissions) => hasAnyPermission(data?.permissions, permissions),
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = React.useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth deve ser usado dentro de AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogout() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return async () => {
|
||||||
|
await authService.logout();
|
||||||
|
queryClient.clear();
|
||||||
|
window.location.href = '/login';
|
||||||
|
};
|
||||||
|
}
|
||||||
12
apps/frontend/src/hooks/use-debounce.ts
Normal file
12
apps/frontend/src/hooks/use-debounce.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delayMs = 300): T {
|
||||||
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = setTimeout(() => setDebounced(value), delayMs);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
}, [value, delayMs]);
|
||||||
|
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
43
apps/frontend/src/hooks/use-theme.tsx
Normal file
43
apps/frontend/src/hooks/use-theme.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
theme: Theme;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = React.createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [theme, setTheme] = React.useState<Theme>('light');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const stored = window.localStorage.getItem('b2bcall-theme') as Theme | null;
|
||||||
|
const initial =
|
||||||
|
stored ??
|
||||||
|
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||||
|
setTheme(initial);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||||
|
window.localStorage.setItem('b2bcall-theme', theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggle = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggle }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const ctx = React.useContext(ThemeContext);
|
||||||
|
if (!ctx) throw new Error('useTheme deve ser usado dentro de ThemeProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
127
apps/frontend/src/lib/api-client.ts
Normal file
127
apps/frontend/src/lib/api-client.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// Padrão relativo: em produção o Nginx expõe frontend e API na mesma
|
||||||
|
// origem (agente.md seção 87), eliminando CORS e problemas de cookie
|
||||||
|
// cross-origin. Só sobrescrever via env em cenários de desenvolvimento
|
||||||
|
// onde a API roda em host/porta diferentes do frontend.
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? '/api';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
details: unknown;
|
||||||
|
|
||||||
|
constructor(status: number, message: string, details?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
async function tryRefresh(): Promise<boolean> {
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = fetch(`${API_URL}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then((res) => res.ok)
|
||||||
|
.catch(() => false)
|
||||||
|
.finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuery(params?: object): string {
|
||||||
|
if (!params) return '';
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params as Record<string, unknown>)) {
|
||||||
|
if (value === undefined || value === null || value === '') continue;
|
||||||
|
search.set(key, String(value));
|
||||||
|
}
|
||||||
|
const qs = search.toString();
|
||||||
|
return qs ? `?${qs}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
params?: object;
|
||||||
|
body?: unknown;
|
||||||
|
isForm?: boolean;
|
||||||
|
retry?: boolean;
|
||||||
|
responseType?: 'json' | 'text' | 'blob';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
opts: RequestOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const { params, body, isForm, retry = true, responseType = 'json' } = opts;
|
||||||
|
|
||||||
|
const init: RequestInit = {
|
||||||
|
method,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: isForm ? undefined : { 'Content-Type': 'application/json' },
|
||||||
|
body: isForm
|
||||||
|
? (body as FormData)
|
||||||
|
: body !== undefined
|
||||||
|
? JSON.stringify(body)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}${path}${buildQuery(params)}`, init);
|
||||||
|
|
||||||
|
if (res.status === 401 && retry && !path.startsWith('/auth/')) {
|
||||||
|
const refreshed = await tryRefresh();
|
||||||
|
if (refreshed) {
|
||||||
|
return request<T>(method, path, { ...opts, retry: false });
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
throw new ApiError(401, 'Sessão expirada');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseType === 'blob') {
|
||||||
|
if (!res.ok) throw new ApiError(res.status, 'Erro ao baixar arquivo');
|
||||||
|
return (await res.blob()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
let data: unknown = undefined;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const message =
|
||||||
|
data && typeof data === 'object' && 'message' in data
|
||||||
|
? Array.isArray((data as { message: unknown }).message)
|
||||||
|
? (data as { message: string[] }).message.join('; ')
|
||||||
|
: String((data as { message: unknown }).message)
|
||||||
|
: `Erro ${res.status}`;
|
||||||
|
throw new ApiError(res.status, message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string, params?: object) =>
|
||||||
|
request<T>('GET', path, { params }),
|
||||||
|
post: <T>(path: string, body?: unknown) => request<T>('POST', path, { body }),
|
||||||
|
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, { body }),
|
||||||
|
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||||
|
upload: <T>(path: string, form: FormData, params?: object) =>
|
||||||
|
request<T>('POST', path, { body: form, isForm: true, params }),
|
||||||
|
download: (path: string, params?: object) =>
|
||||||
|
request<Blob>('GET', path, { params, responseType: 'blob' }),
|
||||||
|
};
|
||||||
7
apps/frontend/src/lib/error-message.ts
Normal file
7
apps/frontend/src/lib/error-message.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { ApiError } from '@/lib/api-client';
|
||||||
|
|
||||||
|
export function errorMessage(err: unknown): string {
|
||||||
|
if (err instanceof ApiError) return err.message;
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return 'Erro inesperado.';
|
||||||
|
}
|
||||||
64
apps/frontend/src/lib/permissions.ts
Normal file
64
apps/frontend/src/lib/permissions.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Espelha packages/shared/src/permissions.ts — fonte de verdade é o backend
|
||||||
|
// (RequirePermissions), isto é só para habilitar/ocultar UI. Nunca confiar
|
||||||
|
// só nisso: toda ação real é reforçada pelo guard do NestJS.
|
||||||
|
export const PERMISSIONS = [
|
||||||
|
'dashboard.view',
|
||||||
|
'trunks.view',
|
||||||
|
'trunks.create',
|
||||||
|
'trunks.update',
|
||||||
|
'trunks.delete',
|
||||||
|
'extensions.view',
|
||||||
|
'extensions.create',
|
||||||
|
'extensions.update',
|
||||||
|
'extensions.delete',
|
||||||
|
'dialplans.view',
|
||||||
|
'dialplans.create',
|
||||||
|
'dialplans.update',
|
||||||
|
'dialplans.delete',
|
||||||
|
'queues.view',
|
||||||
|
'queues.create',
|
||||||
|
'queues.update',
|
||||||
|
'queues.delete',
|
||||||
|
'agents.view',
|
||||||
|
'agents.create',
|
||||||
|
'agents.update',
|
||||||
|
'agents.delete',
|
||||||
|
'campaigns.view',
|
||||||
|
'campaigns.create',
|
||||||
|
'campaigns.start',
|
||||||
|
'campaigns.pause',
|
||||||
|
'campaigns.stop',
|
||||||
|
'campaigns.update',
|
||||||
|
'campaigns.delete',
|
||||||
|
'reports.view',
|
||||||
|
'reports.export',
|
||||||
|
'monitoring.view',
|
||||||
|
'asterisk.view',
|
||||||
|
'asterisk.configure',
|
||||||
|
'asterisk.reload',
|
||||||
|
'users.view',
|
||||||
|
'users.create',
|
||||||
|
'users.update',
|
||||||
|
'roles.manage',
|
||||||
|
'audit.view',
|
||||||
|
'settings.manage',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type Permission = (typeof PERMISSIONS)[number];
|
||||||
|
|
||||||
|
export function hasPermission(
|
||||||
|
userPermissions: string[] | undefined,
|
||||||
|
required: Permission | Permission[],
|
||||||
|
): boolean {
|
||||||
|
if (!userPermissions) return false;
|
||||||
|
const list = Array.isArray(required) ? required : [required];
|
||||||
|
return list.every((p) => userPermissions.includes(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAnyPermission(
|
||||||
|
userPermissions: string[] | undefined,
|
||||||
|
required: Permission[],
|
||||||
|
): boolean {
|
||||||
|
if (!userPermissions) return false;
|
||||||
|
return required.some((p) => userPermissions.includes(p));
|
||||||
|
}
|
||||||
25
apps/frontend/src/lib/utils.ts
Normal file
25
apps/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value: string | Date | null | undefined): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const d = typeof value === 'string' ? new Date(value) : value;
|
||||||
|
return d.toLocaleString('pt-BR');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSeconds(value: number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || Number.isNaN(value)) return '—';
|
||||||
|
const total = Math.round(value);
|
||||||
|
const m = Math.floor(total / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercent(value: number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || Number.isNaN(value)) return '—';
|
||||||
|
return `${(value * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
33
apps/frontend/src/middleware.ts
Normal file
33
apps/frontend/src/middleware.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = ['/login', '/esqueci-senha', '/redefinir-senha'];
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
const hasSession = request.cookies.has('access_token');
|
||||||
|
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p));
|
||||||
|
|
||||||
|
if (!hasSession && !isPublic) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = '/login';
|
||||||
|
url.searchParams.set('next', pathname);
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSession && pathname === '/login') {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = '/';
|
||||||
|
url.search = '';
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Exclui assets internos do Next e qualquer arquivo estático servido de
|
||||||
|
// /public (extensão no último segmento, ex.: /logo.png) — sem isso a
|
||||||
|
// própria logo da tela de login ficava presa atrás do redirect de sessão.
|
||||||
|
matcher: ['/((?!_next/static|_next/image|favicon|icon|.*\\.[\\w]+$).*)'],
|
||||||
|
};
|
||||||
13
apps/frontend/src/services/agent-console.ts
Normal file
13
apps/frontend/src/services/agent-console.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { AgentConsoleState } from '@/types';
|
||||||
|
|
||||||
|
export const agentConsoleService = {
|
||||||
|
me: () => api.get<AgentConsoleState | null>('/agent-console/me'),
|
||||||
|
login: (extension: string) =>
|
||||||
|
api.post<AgentConsoleState>('/agent-console/login', { extension }),
|
||||||
|
available: () => api.post<AgentConsoleState>('/agent-console/available'),
|
||||||
|
pause: (pauseReasonId: string) =>
|
||||||
|
api.post<AgentConsoleState>('/agent-console/pause', { pauseReasonId }),
|
||||||
|
unpause: () => api.post<AgentConsoleState>('/agent-console/unpause'),
|
||||||
|
logout: () => api.post<AgentConsoleState>('/agent-console/logout'),
|
||||||
|
};
|
||||||
18
apps/frontend/src/services/agents.ts
Normal file
18
apps/frontend/src/services/agents.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { Agent } from '@/types';
|
||||||
|
|
||||||
|
export interface AgentInput {
|
||||||
|
code?: string;
|
||||||
|
name: string;
|
||||||
|
userId?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentsService = {
|
||||||
|
list: () => api.get<Agent[]>('/agents'),
|
||||||
|
get: (id: string) => api.get<Agent>(`/agents/${id}`),
|
||||||
|
create: (input: AgentInput) => api.post<Agent>('/agents', input),
|
||||||
|
update: (id: string, input: Partial<AgentInput>) =>
|
||||||
|
api.patch<Agent>(`/agents/${id}`, input),
|
||||||
|
remove: (id: string) => api.delete<void>(`/agents/${id}`),
|
||||||
|
};
|
||||||
14
apps/frontend/src/services/asterisk.ts
Normal file
14
apps/frontend/src/services/asterisk.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { AsteriskStatus, DiagnosticResult } from '@/types';
|
||||||
|
|
||||||
|
export const asteriskService = {
|
||||||
|
status: () => api.get<AsteriskStatus>('/asterisk/status'),
|
||||||
|
modules: () => api.get<unknown>('/asterisk/modules'),
|
||||||
|
allowedCommands: () => api.get<string[]>('/asterisk/diagnostic/allowed-commands'),
|
||||||
|
runDiagnostic: (command: string) =>
|
||||||
|
api.post<DiagnosticResult>('/asterisk/diagnostic', { command }),
|
||||||
|
reload: (module?: string) =>
|
||||||
|
api.post<{ ok: boolean; module: string }>(
|
||||||
|
`/asterisk/reload${module ? `?module=${encodeURIComponent(module)}` : ''}`,
|
||||||
|
),
|
||||||
|
};
|
||||||
17
apps/frontend/src/services/audit.ts
Normal file
17
apps/frontend/src/services/audit.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { AuditLogEntry, Paginated } from '@/types';
|
||||||
|
|
||||||
|
export interface AuditQuery {
|
||||||
|
userId?: string;
|
||||||
|
action?: string;
|
||||||
|
entityType?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auditService = {
|
||||||
|
query: (query: AuditQuery) =>
|
||||||
|
api.get<Paginated<AuditLogEntry>>('/audit', query),
|
||||||
|
};
|
||||||
18
apps/frontend/src/services/auth.ts
Normal file
18
apps/frontend/src/services/auth.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { CurrentUser } from '@/types';
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
login: (email: string, password: string) =>
|
||||||
|
api.post<{ mustChangePassword: boolean }>('/auth/login', { email, password }),
|
||||||
|
logout: () => api.post<void>('/auth/logout'),
|
||||||
|
me: () => api.get<CurrentUser>('/auth/me'),
|
||||||
|
changePassword: (currentPassword: string, newPassword: string) =>
|
||||||
|
api.post<{ ok: boolean }>('/auth/change-password', {
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
}),
|
||||||
|
forgotPassword: (email: string) =>
|
||||||
|
api.post<{ ok: boolean }>('/auth/forgot-password', { email }),
|
||||||
|
resetPassword: (token: string, newPassword: string) =>
|
||||||
|
api.post<{ ok: boolean }>('/auth/reset-password', { token, newPassword }),
|
||||||
|
};
|
||||||
42
apps/frontend/src/services/campaigns.ts
Normal file
42
apps/frontend/src/services/campaigns.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { Campaign } from '@/types';
|
||||||
|
|
||||||
|
export interface CampaignInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
queueId: string;
|
||||||
|
trunkId: string;
|
||||||
|
callerId?: string;
|
||||||
|
context?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
daysOfWeek?: number[];
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
timezone?: string;
|
||||||
|
maxCps: number;
|
||||||
|
maxConcurrentCalls: number;
|
||||||
|
pacingInitial?: number;
|
||||||
|
pacingMin?: number;
|
||||||
|
pacingMax?: number;
|
||||||
|
targetAbandonRate?: number;
|
||||||
|
maxWaitForAgentSeconds?: number;
|
||||||
|
ringTimeoutSeconds?: number;
|
||||||
|
maxAttempts?: number;
|
||||||
|
retryRules?: Record<string, number>;
|
||||||
|
amdEnabled?: boolean;
|
||||||
|
wrapUpTimeSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const campaignsService = {
|
||||||
|
list: () => api.get<Campaign[]>('/campaigns'),
|
||||||
|
get: (id: string) => api.get<Campaign>(`/campaigns/${id}`),
|
||||||
|
create: (input: CampaignInput) => api.post<Campaign>('/campaigns', input),
|
||||||
|
update: (id: string, input: Partial<CampaignInput>) =>
|
||||||
|
api.patch<Campaign>(`/campaigns/${id}`, input),
|
||||||
|
remove: (id: string) => api.delete<void>(`/campaigns/${id}`),
|
||||||
|
start: (id: string) => api.post<Campaign>(`/campaigns/${id}/start`),
|
||||||
|
pause: (id: string) => api.post<Campaign>(`/campaigns/${id}/pause`),
|
||||||
|
stop: (id: string) => api.post<Campaign>(`/campaigns/${id}/stop`),
|
||||||
|
drain: (id: string) => api.post<Campaign>(`/campaigns/${id}/drain`),
|
||||||
|
};
|
||||||
16
apps/frontend/src/services/compliance.ts
Normal file
16
apps/frontend/src/services/compliance.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { ComplianceSettings, ComplianceIndicators } from '@/types';
|
||||||
|
|
||||||
|
export interface UpdateComplianceInput {
|
||||||
|
shortCallThresholdSeconds?: number;
|
||||||
|
maxAttemptsPerNumberPerDay?: number;
|
||||||
|
maxAttemptsPerNumberPerMonth?: number;
|
||||||
|
highVolumeMonthlyThreshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const complianceService = {
|
||||||
|
getSettings: () => api.get<ComplianceSettings>('/compliance/settings'),
|
||||||
|
updateSettings: (input: UpdateComplianceInput) =>
|
||||||
|
api.patch<ComplianceSettings>('/compliance/settings', input),
|
||||||
|
indicators: () => api.get<ComplianceIndicators>('/compliance/indicators'),
|
||||||
|
};
|
||||||
12
apps/frontend/src/services/dashboard.ts
Normal file
12
apps/frontend/src/services/dashboard.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { DashboardOverview, CampaignDashboard } from '@/types';
|
||||||
|
|
||||||
|
export const dashboardService = {
|
||||||
|
overview: () => api.get<DashboardOverview>('/dashboard'),
|
||||||
|
callsByHour: () =>
|
||||||
|
api.get<{ hour: number; total: number; answered: number }[]>(
|
||||||
|
'/dashboard/calls-by-hour',
|
||||||
|
),
|
||||||
|
campaign: (id: string) =>
|
||||||
|
api.get<CampaignDashboard>(`/dashboard/campaigns/${id}`),
|
||||||
|
};
|
||||||
26
apps/frontend/src/services/dialplan.ts
Normal file
26
apps/frontend/src/services/dialplan.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { DialplanEntry, DialplanVersion } from '@/types';
|
||||||
|
|
||||||
|
export interface DialplanEntryInput {
|
||||||
|
context: string;
|
||||||
|
exten: string;
|
||||||
|
priority: number;
|
||||||
|
application: string;
|
||||||
|
argument?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dialplanService = {
|
||||||
|
list: (context?: string) =>
|
||||||
|
api.get<DialplanEntry[]>('/dialplans', context ? { context } : undefined),
|
||||||
|
versions: () => api.get<DialplanVersion[]>('/dialplans/versions'),
|
||||||
|
create: (input: DialplanEntryInput) =>
|
||||||
|
api.post<DialplanEntry>('/dialplans', input),
|
||||||
|
update: (id: string, input: Partial<DialplanEntryInput>) =>
|
||||||
|
api.patch<DialplanEntry>(`/dialplans/${id}`, input),
|
||||||
|
remove: (id: string) => api.delete<void>(`/dialplans/${id}`),
|
||||||
|
publish: () => api.post<DialplanVersion>('/dialplans/publish'),
|
||||||
|
rollback: (versionId: string) =>
|
||||||
|
api.post<DialplanVersion>(`/dialplans/versions/${versionId}/rollback`),
|
||||||
|
};
|
||||||
19
apps/frontend/src/services/dispositions.ts
Normal file
19
apps/frontend/src/services/dispositions.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { Disposition, DispositionAction } from '@/types';
|
||||||
|
|
||||||
|
export interface DispositionInput {
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
description?: string;
|
||||||
|
action?: DispositionAction;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dispositionsService = {
|
||||||
|
list: () => api.get<Disposition[]>('/dispositions'),
|
||||||
|
create: (input: DispositionInput) =>
|
||||||
|
api.post<Disposition>('/dispositions', input),
|
||||||
|
update: (id: string, input: Partial<DispositionInput>) =>
|
||||||
|
api.patch<Disposition>(`/dispositions/${id}`, input),
|
||||||
|
remove: (id: string) => api.delete<void>(`/dispositions/${id}`),
|
||||||
|
};
|
||||||
25
apps/frontend/src/services/extensions.ts
Normal file
25
apps/frontend/src/services/extensions.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { Extension } from '@/types';
|
||||||
|
|
||||||
|
export interface ExtensionInput {
|
||||||
|
number?: string;
|
||||||
|
name: string;
|
||||||
|
callerId?: string;
|
||||||
|
context?: string;
|
||||||
|
codecs?: string[];
|
||||||
|
transport?: string;
|
||||||
|
maxContacts?: number;
|
||||||
|
qualifyFrequency?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extensionsService = {
|
||||||
|
list: () => api.get<Extension[]>('/extensions'),
|
||||||
|
get: (id: string) => api.get<Extension>(`/extensions/${id}`),
|
||||||
|
create: (input: ExtensionInput) => api.post<Extension>('/extensions', input),
|
||||||
|
update: (id: string, input: Partial<ExtensionInput>) =>
|
||||||
|
api.patch<Extension>(`/extensions/${id}`, input),
|
||||||
|
resetPassword: (id: string) =>
|
||||||
|
api.post<Extension>(`/extensions/${id}/reset-password`),
|
||||||
|
remove: (id: string) => api.delete<void>(`/extensions/${id}`),
|
||||||
|
};
|
||||||
22
apps/frontend/src/services/leads.ts
Normal file
22
apps/frontend/src/services/leads.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { Lead, LeadImport, LeadStatus, Paginated, ImportResult } from '@/types';
|
||||||
|
|
||||||
|
export const leadsService = {
|
||||||
|
query: (
|
||||||
|
campaignId: string,
|
||||||
|
params: { status?: LeadStatus; search?: string; page?: number; pageSize?: number },
|
||||||
|
) => api.get<Paginated<Lead>>(`/campaigns/${campaignId}/leads`, params),
|
||||||
|
imports: (campaignId: string) =>
|
||||||
|
api.get<LeadImport[]>(`/campaigns/${campaignId}/leads/imports`),
|
||||||
|
importCsv: (campaignId: string, file: File, dryRun = false) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
return api.upload<ImportResult>(
|
||||||
|
`/campaigns/${campaignId}/leads/import`,
|
||||||
|
form,
|
||||||
|
{ dryRun: dryRun ? 'true' : undefined },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
downloadRejected: (campaignId: string, importId: string) =>
|
||||||
|
api.download(`/campaigns/${campaignId}/leads/imports/${importId}/rejected.csv`),
|
||||||
|
};
|
||||||
8
apps/frontend/src/services/monitoring.ts
Normal file
8
apps/frontend/src/services/monitoring.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { ExtensionMonitor, QueueMonitor, AgentMonitor } from '@/types';
|
||||||
|
|
||||||
|
export const monitoringService = {
|
||||||
|
extensions: () => api.get<ExtensionMonitor[]>('/monitoring/extensions'),
|
||||||
|
queues: () => api.get<QueueMonitor[]>('/monitoring/queues'),
|
||||||
|
agents: () => api.get<AgentMonitor[]>('/monitoring/agents'),
|
||||||
|
};
|
||||||
20
apps/frontend/src/services/pause-reasons.ts
Normal file
20
apps/frontend/src/services/pause-reasons.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { PauseReason } from '@/types';
|
||||||
|
|
||||||
|
export interface PauseReasonInput {
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
description?: string;
|
||||||
|
maxDurationSeconds?: number;
|
||||||
|
paid?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pauseReasonsService = {
|
||||||
|
list: () => api.get<PauseReason[]>('/pause-reasons'),
|
||||||
|
create: (input: PauseReasonInput) =>
|
||||||
|
api.post<PauseReason>('/pause-reasons', input),
|
||||||
|
update: (id: string, input: Partial<PauseReasonInput>) =>
|
||||||
|
api.patch<PauseReason>(`/pause-reasons/${id}`, input),
|
||||||
|
remove: (id: string) => api.delete<void>(`/pause-reasons/${id}`),
|
||||||
|
};
|
||||||
32
apps/frontend/src/services/queues.ts
Normal file
32
apps/frontend/src/services/queues.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { Queue, QueueStrategy } from '@/types';
|
||||||
|
|
||||||
|
export interface QueueInput {
|
||||||
|
name?: string;
|
||||||
|
number?: string;
|
||||||
|
strategy?: QueueStrategy;
|
||||||
|
timeout?: number;
|
||||||
|
retry?: number;
|
||||||
|
wrapUpTime?: number;
|
||||||
|
maxLen?: number;
|
||||||
|
musicOnHold?: string;
|
||||||
|
announce?: string;
|
||||||
|
serviceLevel?: number;
|
||||||
|
autoFill?: boolean;
|
||||||
|
ringInUse?: boolean;
|
||||||
|
weight?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const queuesService = {
|
||||||
|
list: () => api.get<Queue[]>('/queues'),
|
||||||
|
get: (id: string) => api.get<Queue>(`/queues/${id}`),
|
||||||
|
create: (input: QueueInput) => api.post<Queue>('/queues', input),
|
||||||
|
update: (id: string, input: Partial<QueueInput>) =>
|
||||||
|
api.patch<Queue>(`/queues/${id}`, input),
|
||||||
|
remove: (id: string) => api.delete<void>(`/queues/${id}`),
|
||||||
|
addMember: (id: string, agentId: string, penalty?: number) =>
|
||||||
|
api.post(`/queues/${id}/members`, { agentId, penalty }),
|
||||||
|
removeMember: (id: string, agentId: string) =>
|
||||||
|
api.delete(`/queues/${id}/members/${agentId}`),
|
||||||
|
};
|
||||||
26
apps/frontend/src/services/reports.ts
Normal file
26
apps/frontend/src/services/reports.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { CallsReportResult, CallMetrics, AgentReport } from '@/types';
|
||||||
|
|
||||||
|
export interface CallsReportQuery {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
campaignId?: string;
|
||||||
|
queueId?: string;
|
||||||
|
agentId?: string;
|
||||||
|
dispositionId?: string;
|
||||||
|
phone?: string;
|
||||||
|
state?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reportsService = {
|
||||||
|
calls: (query: CallsReportQuery) =>
|
||||||
|
api.get<CallsReportResult>('/reports/calls', query),
|
||||||
|
exportCalls: (query: CallsReportQuery) =>
|
||||||
|
api.download('/reports/calls/export', query),
|
||||||
|
metrics: (query: Pick<CallsReportQuery, 'campaignId' | 'from' | 'to'>) =>
|
||||||
|
api.get<CallMetrics>('/reports/metrics', query),
|
||||||
|
agentReport: (agentId: string, from?: string, to?: string) =>
|
||||||
|
api.get<AgentReport>(`/reports/agents/${agentId}`, { from, to }),
|
||||||
|
};
|
||||||
23
apps/frontend/src/services/roles.ts
Normal file
23
apps/frontend/src/services/roles.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { Role } from '@/types';
|
||||||
|
import type { Permission } from '@/lib/permissions';
|
||||||
|
|
||||||
|
export interface CreateRoleInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
permissionKeys: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRoleInput {
|
||||||
|
description?: string;
|
||||||
|
permissionKeys?: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rolesService = {
|
||||||
|
list: () => api.get<Role[]>('/roles'),
|
||||||
|
permissionCatalog: () => api.get<Permission[]>('/roles/permissions'),
|
||||||
|
create: (input: CreateRoleInput) => api.post<Role>('/roles', input),
|
||||||
|
update: (id: string, input: UpdateRoleInput) =>
|
||||||
|
api.patch<Role>(`/roles/${id}`, input),
|
||||||
|
remove: (id: string) => api.delete<void>(`/roles/${id}`),
|
||||||
|
};
|
||||||
21
apps/frontend/src/services/suppression.ts
Normal file
21
apps/frontend/src/services/suppression.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { SuppressionEntry, Paginated } from '@/types';
|
||||||
|
|
||||||
|
export interface SuppressionImportResult {
|
||||||
|
added: number;
|
||||||
|
invalid: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const suppressionService = {
|
||||||
|
query: (params: { search?: string; page?: number; pageSize?: number }) =>
|
||||||
|
api.get<Paginated<SuppressionEntry>>('/suppression', params),
|
||||||
|
add: (phone: string, reason?: string) =>
|
||||||
|
api.post<SuppressionEntry>('/suppression', { phone, reason }),
|
||||||
|
importCsv: (file: File) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
return api.upload<SuppressionImportResult>('/suppression/import', form);
|
||||||
|
},
|
||||||
|
remove: (id: string) => api.delete<void>(`/suppression/${id}`),
|
||||||
|
};
|
||||||
35
apps/frontend/src/services/trunks.ts
Normal file
35
apps/frontend/src/services/trunks.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { Trunk, TrunkType, DtmfMode } from '@/types';
|
||||||
|
|
||||||
|
export interface TrunkInput {
|
||||||
|
name?: string;
|
||||||
|
type?: TrunkType;
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
transport?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
fromUser?: string;
|
||||||
|
fromDomain?: string;
|
||||||
|
contactUser?: string;
|
||||||
|
outboundProxy?: string;
|
||||||
|
context?: string;
|
||||||
|
callerId?: string;
|
||||||
|
codecs?: string[];
|
||||||
|
dtmfMode?: DtmfMode;
|
||||||
|
qualifyFrequency?: number;
|
||||||
|
maxChannels?: number;
|
||||||
|
maxCps: number;
|
||||||
|
allowedIps?: string[];
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trunksService = {
|
||||||
|
list: () => api.get<Trunk[]>('/trunks'),
|
||||||
|
get: (id: string) => api.get<Trunk>(`/trunks/${id}`),
|
||||||
|
status: (id: string) => api.get<{ status: string }>(`/trunks/${id}/status`),
|
||||||
|
create: (input: TrunkInput) => api.post<Trunk>('/trunks', input),
|
||||||
|
update: (id: string, input: Partial<TrunkInput>) =>
|
||||||
|
api.patch<Trunk>(`/trunks/${id}`, input),
|
||||||
|
remove: (id: string) => api.delete<void>(`/trunks/${id}`),
|
||||||
|
};
|
||||||
22
apps/frontend/src/services/users.ts
Normal file
22
apps/frontend/src/services/users.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { UserRecord } from '@/types';
|
||||||
|
|
||||||
|
export interface CreateUserInput {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
roleIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserInput {
|
||||||
|
name?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
roleIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usersService = {
|
||||||
|
list: () => api.get<UserRecord[]>('/users'),
|
||||||
|
get: (id: string) => api.get<UserRecord>(`/users/${id}`),
|
||||||
|
create: (input: CreateUserInput) => api.post<UserRecord>('/users', input),
|
||||||
|
update: (id: string, input: UpdateUserInput) =>
|
||||||
|
api.patch<UserRecord>(`/users/${id}`, input),
|
||||||
|
};
|
||||||
459
apps/frontend/src/types/index.ts
Normal file
459
apps/frontend/src/types/index.ts
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
export interface CurrentUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
isSystem: boolean;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
isSystem: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
isActive: boolean;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
roles: RoleSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrunkType = 'IP' | 'REGISTRATION';
|
||||||
|
export type DtmfMode = 'rfc4733' | 'info' | 'inband';
|
||||||
|
|
||||||
|
export interface Trunk {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: TrunkType;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
transport: string;
|
||||||
|
username: string | null;
|
||||||
|
fromUser: string | null;
|
||||||
|
fromDomain: string | null;
|
||||||
|
contactUser: string | null;
|
||||||
|
outboundProxy: string | null;
|
||||||
|
context: string;
|
||||||
|
callerId: string | null;
|
||||||
|
codecs: string[];
|
||||||
|
dtmfMode: DtmfMode;
|
||||||
|
qualifyFrequency: number;
|
||||||
|
maxChannels: number | null;
|
||||||
|
maxCps: number;
|
||||||
|
allowedIps: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Extension {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
name: string;
|
||||||
|
callerId: string | null;
|
||||||
|
context: string;
|
||||||
|
codecs: string[];
|
||||||
|
transport: string;
|
||||||
|
maxContacts: number;
|
||||||
|
qualifyFrequency: number;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
sipPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionMonitor {
|
||||||
|
number: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
deviceState: string | null;
|
||||||
|
contactStatus: string | null;
|
||||||
|
status: 'online' | 'offline' | 'busy' | 'unknown';
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialplanEntry {
|
||||||
|
id: string;
|
||||||
|
context: string;
|
||||||
|
exten: string;
|
||||||
|
priority: number;
|
||||||
|
application: string;
|
||||||
|
argument: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
order: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialplanVersion {
|
||||||
|
id: string;
|
||||||
|
status: 'APPLIED' | 'FAILED' | 'ROLLED_BACK';
|
||||||
|
reloadResult: string | null;
|
||||||
|
createdById: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueueStrategy =
|
||||||
|
| 'ringall'
|
||||||
|
| 'leastrecent'
|
||||||
|
| 'fewestcalls'
|
||||||
|
| 'random'
|
||||||
|
| 'rrmemory'
|
||||||
|
| 'rrordered'
|
||||||
|
| 'linear'
|
||||||
|
| 'wrandom';
|
||||||
|
|
||||||
|
export interface Queue {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
number: string;
|
||||||
|
strategy: QueueStrategy;
|
||||||
|
timeout: number;
|
||||||
|
retry: number;
|
||||||
|
wrapUpTime: number;
|
||||||
|
maxLen: number;
|
||||||
|
musicOnHold: string;
|
||||||
|
announce: string | null;
|
||||||
|
serviceLevel: number;
|
||||||
|
autoFill: boolean;
|
||||||
|
ringInUse: boolean;
|
||||||
|
weight: number;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
members?: { agentId: string; penalty: number; agent?: Agent }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueMonitor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
number: string;
|
||||||
|
strategy: QueueStrategy;
|
||||||
|
callsWaiting: number;
|
||||||
|
longestWaitSeconds: number;
|
||||||
|
agentsLoggedIn: number;
|
||||||
|
agentsPaused: number;
|
||||||
|
agentsAvailable: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Agent {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
userId: string;
|
||||||
|
active: boolean;
|
||||||
|
currentExtension: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
user?: { id: string; name: string; email: string };
|
||||||
|
queues?: { queue: Queue }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentState =
|
||||||
|
| 'OFFLINE'
|
||||||
|
| 'LOGGED_IN'
|
||||||
|
| 'AVAILABLE'
|
||||||
|
| 'RINGING'
|
||||||
|
| 'IN_CALL'
|
||||||
|
| 'WRAP_UP'
|
||||||
|
| 'PAUSED'
|
||||||
|
| 'LOGGED_OUT';
|
||||||
|
|
||||||
|
export interface AgentMonitor {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
userName: string;
|
||||||
|
currentExtension: string | null;
|
||||||
|
queues: string[];
|
||||||
|
state: AgentState;
|
||||||
|
stateSince: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PauseReason {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description: string | null;
|
||||||
|
maxDurationSeconds: number | null;
|
||||||
|
paid: boolean;
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DispositionAction = 'NONE' | 'CALLBACK' | 'DO_NOT_CALL';
|
||||||
|
|
||||||
|
export interface Disposition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description: string | null;
|
||||||
|
action: DispositionAction;
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CampaignStatus =
|
||||||
|
| 'DRAFT'
|
||||||
|
| 'READY'
|
||||||
|
| 'RUNNING'
|
||||||
|
| 'PAUSED'
|
||||||
|
| 'DRAINING'
|
||||||
|
| 'STOPPED'
|
||||||
|
| 'COMPLETED';
|
||||||
|
|
||||||
|
export interface Campaign {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
queueId: string;
|
||||||
|
trunkId: string;
|
||||||
|
callerId: string | null;
|
||||||
|
context: string;
|
||||||
|
status: CampaignStatus;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
daysOfWeek: number[];
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
timezone: string;
|
||||||
|
maxCps: number;
|
||||||
|
maxConcurrentCalls: number;
|
||||||
|
pacingInitial: number;
|
||||||
|
pacingMin: number;
|
||||||
|
pacingMax: number;
|
||||||
|
targetAbandonRate: number;
|
||||||
|
maxWaitForAgentSeconds: number;
|
||||||
|
ringTimeoutSeconds: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
retryRules: Record<string, number>;
|
||||||
|
amdEnabled: boolean;
|
||||||
|
wrapUpTimeSeconds: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LeadStatus =
|
||||||
|
| 'NEW'
|
||||||
|
| 'READY'
|
||||||
|
| 'RESERVED'
|
||||||
|
| 'DIALING'
|
||||||
|
| 'RINGING'
|
||||||
|
| 'BUSY'
|
||||||
|
| 'NO_ANSWER'
|
||||||
|
| 'FAILED'
|
||||||
|
| 'COMPLETED'
|
||||||
|
| 'MAX_ATTEMPTS'
|
||||||
|
| 'DO_NOT_CALL'
|
||||||
|
| 'INVALID';
|
||||||
|
|
||||||
|
export interface Lead {
|
||||||
|
id: string;
|
||||||
|
campaignId: string;
|
||||||
|
importId: string | null;
|
||||||
|
name: string | null;
|
||||||
|
phone: string;
|
||||||
|
phoneNormalized: string;
|
||||||
|
status: LeadStatus;
|
||||||
|
attemptCount: number;
|
||||||
|
lastAttemptAt: string | null;
|
||||||
|
nextAttemptAt: string | null;
|
||||||
|
lastResult: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadImport {
|
||||||
|
id: string;
|
||||||
|
campaignId: string;
|
||||||
|
filename: string;
|
||||||
|
status: string;
|
||||||
|
totalRows: number;
|
||||||
|
validRows: number;
|
||||||
|
invalidRows: number;
|
||||||
|
duplicateRows: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
total: number;
|
||||||
|
valid: number;
|
||||||
|
invalid: number;
|
||||||
|
duplicate: number;
|
||||||
|
rejectedCsv: string | null;
|
||||||
|
importId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CallState =
|
||||||
|
| 'CREATED'
|
||||||
|
| 'RESERVED'
|
||||||
|
| 'ORIGINATING'
|
||||||
|
| 'RINGING'
|
||||||
|
| 'ANSWERED'
|
||||||
|
| 'QUEUED'
|
||||||
|
| 'AGENT_CONNECTED'
|
||||||
|
| 'COMPLETED'
|
||||||
|
| 'FAILED';
|
||||||
|
|
||||||
|
export interface DialAttempt {
|
||||||
|
id: string;
|
||||||
|
leadId: string;
|
||||||
|
campaignId: string;
|
||||||
|
state: CallState;
|
||||||
|
calledNumber: string;
|
||||||
|
callerIdUsed: string | null;
|
||||||
|
agentId: string | null;
|
||||||
|
dispositionId: string | null;
|
||||||
|
dispositionNotes: string | null;
|
||||||
|
amdResult: string | null;
|
||||||
|
hangupCause: string | null;
|
||||||
|
startedAt: string;
|
||||||
|
ringingAt: string | null;
|
||||||
|
answeredAt: string | null;
|
||||||
|
queuedAt: string | null;
|
||||||
|
agentConnectedAt: string | null;
|
||||||
|
endedAt: string | null;
|
||||||
|
lead?: { name: string | null; phone: string };
|
||||||
|
campaign?: { name: string };
|
||||||
|
disposition?: { name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallsReportResult {
|
||||||
|
items: DialAttempt[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallMetrics {
|
||||||
|
tmeSeconds: number;
|
||||||
|
avgAbandonWaitSeconds: number;
|
||||||
|
tmaSeconds: number;
|
||||||
|
totalAttempts: number;
|
||||||
|
answeredCount: number;
|
||||||
|
abandonedCount: number;
|
||||||
|
answerRate: number;
|
||||||
|
abandonRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentReport {
|
||||||
|
timeByStateSeconds: Record<string, number>;
|
||||||
|
callsAnswered: number;
|
||||||
|
tmaSeconds: number;
|
||||||
|
pauses: {
|
||||||
|
reason: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
durationSeconds: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardOverview {
|
||||||
|
callsToday: number;
|
||||||
|
answeredToday: number;
|
||||||
|
inProgress: number;
|
||||||
|
waitingForAgent: number;
|
||||||
|
agentsAvailable: number;
|
||||||
|
agentsBusy: number;
|
||||||
|
agentsPaused: number;
|
||||||
|
tmeSeconds: number;
|
||||||
|
tmaSeconds: number;
|
||||||
|
answerRate: number;
|
||||||
|
abandonRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignDashboard {
|
||||||
|
status: CampaignStatus;
|
||||||
|
maxCps: number;
|
||||||
|
cpsAtual: number;
|
||||||
|
dialing: number;
|
||||||
|
ringing: number;
|
||||||
|
answered: number;
|
||||||
|
queued: number;
|
||||||
|
connected: number;
|
||||||
|
leadsRemaining: number;
|
||||||
|
leadsProcessed: number;
|
||||||
|
pacingFactor: number | null;
|
||||||
|
answerProbability: number | null;
|
||||||
|
abandonRate: number | null;
|
||||||
|
avgTalkTimeSeconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComplianceSettings {
|
||||||
|
id: string;
|
||||||
|
shortCallThresholdSeconds: number;
|
||||||
|
maxAttemptsPerNumberPerDay: number;
|
||||||
|
maxAttemptsPerNumberPerMonth: number;
|
||||||
|
highVolumeMonthlyThreshold: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComplianceIndicators {
|
||||||
|
settings: ComplianceSettings;
|
||||||
|
totalCallsToday: number;
|
||||||
|
answeredCallsToday: number;
|
||||||
|
shortCallsToday: number;
|
||||||
|
abandonedCallsToday: number;
|
||||||
|
totalCallsMonth: number;
|
||||||
|
numbersOverDailyLimit: { phoneNormalized: string; attempts: number }[];
|
||||||
|
alerts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuppressionEntry {
|
||||||
|
id: string;
|
||||||
|
phoneNormalized: string;
|
||||||
|
reason: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
id: string;
|
||||||
|
userId: string | null;
|
||||||
|
action: string;
|
||||||
|
entityType: string | null;
|
||||||
|
entityId: string | null;
|
||||||
|
before: unknown;
|
||||||
|
after: unknown;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
user?: { name: string; email: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Paginated<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentConsoleState {
|
||||||
|
agent: Agent;
|
||||||
|
state: AgentState;
|
||||||
|
stateSince: string;
|
||||||
|
currentPause: { pauseReason: { name: string }; startedAt: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AsteriskStatus {
|
||||||
|
amiControlConnection: 'up' | 'down';
|
||||||
|
asteriskEventsHeartbeat: 'up' | 'down';
|
||||||
|
lastHeartbeat: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagnosticResult {
|
||||||
|
command: string;
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
24
apps/frontend/tsconfig.json
Normal file
24
apps/frontend/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -203,6 +203,70 @@ services:
|
|||||||
limits:
|
limits:
|
||||||
memory: 150M
|
memory: 150M
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: infrastructure/docker/frontend.Dockerfile
|
||||||
|
image: b2bcall-frontend:0.1.0
|
||||||
|
container_name: b2bcall-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- b2bcall-net
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
# Sem "ports": só o Nginx expõe HTTP ao mundo externo (agente.md seção
|
||||||
|
# 87) — o browser fala só com o Nginx, nunca direto com este container.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/login', r => process.exit(r.statusCode < 500 ? 0 : 1)).on('error', () => process.exit(1))"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 15s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 200M
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
container_name: b2bcall-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- b2bcall-net
|
||||||
|
depends_on:
|
||||||
|
frontend:
|
||||||
|
condition: service_healthy
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./infrastructure/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
ports:
|
||||||
|
# Único serviço exposto à LAN (agente.md seção 87) — todo o resto
|
||||||
|
# (api, frontend, postgres, redis, asterisk AMI/ARI) fica só na rede
|
||||||
|
# interna do Docker ou protegido por nftables.
|
||||||
|
- "80:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:80/login"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 64M
|
||||||
|
|
||||||
dialer-worker:
|
dialer-worker:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
@@ -154,6 +154,34 @@ Prisma em vez de tentar modelar lock otimista via ORM.
|
|||||||
índices e chaves são desenhados para permitir particionamento futuro sem
|
índices e chaves são desenhados para permitir particionamento futuro sem
|
||||||
migração destrutiva.
|
migração destrutiva.
|
||||||
|
|
||||||
|
### 3.7.1 Frontend e reverse proxy (Fase 8)
|
||||||
|
|
||||||
|
- `apps/frontend`: Next.js 15 (App Router), React 19, Tailwind CSS v4,
|
||||||
|
componentes estilo shadcn/ui escritos à mão sobre Radix UI (sem depender
|
||||||
|
do CLI interativo do shadcn), TanStack Query para data-fetching/cache. SPA
|
||||||
|
client-rendered atrás de RBAC (permissões vêm de `/api/auth/me`, cada tela
|
||||||
|
usa `RequirePermission` além de já esconder itens de menu sem a permissão
|
||||||
|
— defesa em profundidade, a autorização real é sempre do backend).
|
||||||
|
- Build com `output: "standalone"` (Next.js) — a imagem de runtime
|
||||||
|
(`infrastructure/docker/frontend.Dockerfile`) não precisa de
|
||||||
|
pnpm/instalação nenhuma, só copia a árvore podada gerada pelo build.
|
||||||
|
Atenção: em monorepo pnpm os `node_modules` do standalone contêm symlinks
|
||||||
|
relativos para o pnpm store (`../../../node_modules/.pnpm/...`) — a
|
||||||
|
imagem copia a árvore `apps/frontend` inteira sem achatar diretórios,
|
||||||
|
senão os symlinks apontam para fora do container.
|
||||||
|
- **Nginx** (`infrastructure/nginx/nginx.conf`, imagem oficial `nginx:alpine`)
|
||||||
|
é o único serviço publicado para a LAN (porta 80, agente.md seção 87) —
|
||||||
|
faz proxy de `/api/*` para o container `api` e do restante para o
|
||||||
|
`frontend`, ambos na rede Docker interna sem portas publicadas
|
||||||
|
individualmente. Isso coloca frontend e API na mesma origem do ponto de
|
||||||
|
vista do navegador, eliminando CORS e problemas de cookie cross-origin
|
||||||
|
(o cliente da API em `apps/frontend/src/lib/api-client.ts` usa por padrão
|
||||||
|
a base relativa `/api`). HTTPS/TLS ainda não configurado — ver seção 87
|
||||||
|
do `agente.md` e pendências na Fase 9 do `TODO.md`.
|
||||||
|
- `next.config.ts` tem `eslint: { ignoreDuringBuilds: true }` — o lint roda
|
||||||
|
como etapa separada da pipeline de qualidade (`pnpm --filter
|
||||||
|
@b2bcall/frontend lint`), não durante o build de produção.
|
||||||
|
|
||||||
### 3.8 Segurança desde a arquitetura
|
### 3.8 Segurança desde a arquitetura
|
||||||
|
|
||||||
- Segredos apenas em `.env` (nunca commitado). Credenciais de trunk SIP
|
- Segredos apenas em `.env` (nunca commitado). Credenciais de trunk SIP
|
||||||
|
|||||||
36
infrastructure/docker/frontend.Dockerfile
Normal file
36
infrastructure/docker/frontend.Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# apps/frontend — Next.js (App Router), monorepo pnpm, output standalone.
|
||||||
|
FROM node:24-slim AS build
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.24.0 --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
|
COPY apps/frontend/package.json apps/frontend/package.json
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile --filter @b2bcall/frontend...
|
||||||
|
|
||||||
|
COPY apps/frontend apps/frontend
|
||||||
|
|
||||||
|
RUN pnpm --filter @b2bcall/frontend build
|
||||||
|
|
||||||
|
# --- runtime -------------------------------------------------------------
|
||||||
|
FROM node:24-slim AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN groupadd -r b2bcall && useradd -r -g b2bcall b2bcall
|
||||||
|
|
||||||
|
# next.config.ts com output:"standalone" produz uma árvore mínima com só o
|
||||||
|
# necessário para rodar. Em monorepo pnpm o resultado preserva a estrutura
|
||||||
|
# apps/frontend + node_modules na raiz com symlinks relativos para o pnpm
|
||||||
|
# store — por isso a árvore inteira é copiada sem "achatar" os diretórios,
|
||||||
|
# senão os symlinks (../../../node_modules/.pnpm/...) apontam para fora.
|
||||||
|
COPY --from=build /app/apps/frontend/.next/standalone ./
|
||||||
|
COPY --from=build /app/apps/frontend/.next/static ./apps/frontend/.next/static
|
||||||
|
COPY --from=build /app/apps/frontend/public ./apps/frontend/public
|
||||||
|
|
||||||
|
USER b2bcall
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "apps/frontend/server.js"]
|
||||||
59
infrastructure/nginx/nginx.conf
Normal file
59
infrastructure/nginx/nginx.conf
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Reverse proxy único (agente.md seção 87): expõe frontend + API na mesma
|
||||||
|
# origem para o navegador, o que também elimina CORS e problemas de cookie
|
||||||
|
# cross-origin — frontend e API nunca ficam expostos diretamente ao host.
|
||||||
|
worker_processes 1;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 512;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
client_max_body_size 55m;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - [$time_local] "$request" $status $body_bytes_sent "$http_referer"';
|
||||||
|
access_log /dev/stdout main;
|
||||||
|
error_log /dev/stderr warn;
|
||||||
|
|
||||||
|
upstream frontend_upstream {
|
||||||
|
server frontend:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream api_upstream {
|
||||||
|
server api:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /_next/webpack-hmr {
|
||||||
|
proxy_pass http://frontend_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3702
pnpm-lock.yaml
generated
3702
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user