diff --git a/TODO.md b/TODO.md index 49c3829..81be727 100644 --- a/TODO.md +++ b/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. ## Fase 8 — Frontend completo -- [ ] Bootstrap Next.js + Tailwind + shadcn/ui + TanStack Query + WS client -- [ ] Logo processada (b2bcall.png) + tema light/dark -- [ ] Menu completo (seção 52) -- [ ] Todas as telas do checklist de aceite (seção 90) +- [x] Bootstrap Next.js 15 (App Router) + Tailwind v4 + componentes estilo + shadcn/ui (escritos à mão sobre Radix UI, sem CLI interativo) + + TanStack Query. WS client não implementado (ver nota abaixo) — + 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 - [ ] Criptografia de segredos de trunk (AES-256-GCM) diff --git a/apps/api/src/monitoring/monitoring.controller.ts b/apps/api/src/monitoring/monitoring.controller.ts index 889662e..13318d0 100644 --- a/apps/api/src/monitoring/monitoring.controller.ts +++ b/apps/api/src/monitoring/monitoring.controller.ts @@ -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, + }; + }); + } } diff --git a/apps/api/src/reports/dto/query-calls-report.dto.ts b/apps/api/src/reports/dto/query-calls-report.dto.ts index 7008cc2..f0549d4 100644 --- a/apps/api/src/reports/dto/query-calls-report.dto.ts +++ b/apps/api/src/reports/dto/query-calls-report.dto.ts @@ -22,6 +22,10 @@ export class QueryCallsReportDto { @IsUUID('4') campaignId?: string; + @IsOptional() + @IsUUID('4') + queueId?: string; + @IsOptional() @IsUUID('4') agentId?: string; diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts index fb273bd..e6a0c35 100644 --- a/apps/api/src/reports/reports.service.ts +++ b/apps/api/src/reports/reports.service.ts @@ -13,6 +13,7 @@ function buildCallsWhere( dispositionId: query.dispositionId, state: query.state as CallState | undefined, calledNumber: query.phone ? { contains: query.phone } : undefined, + campaign: query.queueId ? { queueId: query.queueId } : undefined, startedAt: { gte: query.from ? new Date(query.from) : undefined, lte: query.to ? new Date(query.to) : undefined, diff --git a/apps/frontend/eslint.config.mjs b/apps/frontend/eslint.config.mjs new file mode 100644 index 0000000..72342c3 --- /dev/null +++ b/apps/frontend/eslint.config.mjs @@ -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/**'], + }, +]; diff --git a/apps/frontend/next-env.d.ts b/apps/frontend/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/apps/frontend/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts new file mode 100644 index 0000000..ce336b1 --- /dev/null +++ b/apps/frontend/next.config.ts @@ -0,0 +1,9 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + output: 'standalone', + reactStrictMode: true, + eslint: { ignoreDuringBuilds: true }, +}; + +export default nextConfig; diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 0000000..4bfe467 --- /dev/null +++ b/apps/frontend/package.json @@ -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" + } +} diff --git a/apps/frontend/postcss.config.mjs b/apps/frontend/postcss.config.mjs new file mode 100644 index 0000000..297374d --- /dev/null +++ b/apps/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/apps/frontend/public/logo.png b/apps/frontend/public/logo.png new file mode 100644 index 0000000..37ba6e8 Binary files /dev/null and b/apps/frontend/public/logo.png differ diff --git a/apps/frontend/src/app/(app)/agente/page.tsx b/apps/frontend/src/app/(app)/agente/page.tsx new file mode 100644 index 0000000..b940146 --- /dev/null +++ b/apps/frontend/src/app/(app)/agente/page.tsx @@ -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 = { + 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 = { + 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 ( + <> + + + + ); + } + + if (error instanceof ApiError && error.status === 403) { + return ( + <> + + + + Seu usuário não possui um agente de Call Center associado. + + + + ); + } + + const state = me?.state ?? 'OFFLINE'; + + return ( + <> + + + + + Status atual + {STATE_LABEL[state]} + + + + {me?.currentPause && ( +

+ Pausado desde {new Date(me.currentPause.startedAt).toLocaleTimeString('pt-BR')} —{' '} + {me.currentPause.pauseReason.name} +

+ )} + + {state === 'OFFLINE' || state === 'LOGGED_OUT' ? ( +
+
+ + setExtension(e.target.value)} + placeholder="4001" + /> +
+ +
+ ) : ( +
+ {(state === 'LOGGED_IN' || state === 'PAUSED') && ( + + )} + {state === 'PAUSED' ? ( + + ) : ( + state !== 'IN_CALL' && + state !== 'RINGING' && ( +
+ + +
+ ) + )} + +
+ )} +
+
+ + ); +} diff --git a/apps/frontend/src/app/(app)/agentes/page.tsx b/apps/frontend/src/app/(app)/agentes/page.tsx new file mode 100644 index 0000000..98e9557 --- /dev/null +++ b/apps/frontend/src/app/(app)/agentes/page.tsx @@ -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({ 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 ( + + + + {isEdit ? 'Editar agente' : 'Novo agente'} + +
{ + e.preventDefault(); + mutation.mutate(); + }} + className="flex flex-col gap-4" + > + {!isEdit && ( +
+ + setForm((f) => ({ ...f, code: e.target.value }))} + /> +
+ )} +
+ + setForm((f) => ({ ...f, name: e.target.value }))} + /> +
+ {!isEdit && ( +
+ + +
+ )} + + + +
+
+
+ ); +} + +function AgentsContent() { + const { can } = useAuth(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [dialogOpen, setDialogOpen] = React.useState(false); + const [editing, setEditing] = React.useState(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[] = [ + { 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) => ( +
+ {(r.queues ?? []).map((q) => ( + + {q.queue.name} + + ))} + {(r.queues ?? []).length === 0 && '—'} +
+ ), + }, + { + key: 'active', + header: 'Status', + render: (r) => ( + {r.active ? 'Ativo' : 'Inativo'} + ), + }, + { + key: 'actions', + header: '', + className: 'text-right', + render: (r) => ( +
+ {can('agents.update') && ( + + )} + {can('agents.delete') && ( + + )} +
+ ), + }, + ]; + + return ( + <> + { + setEditing(null); + setDialogOpen(true); + }} + > + Novo agente + + ) + } + /> + refetch()} + rowKey={(r) => r.id} + emptyMessage="Nenhum agente cadastrado." + /> + + + ); +} + +export default function AgentsPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/asterisk/page.tsx b/apps/frontend/src/app/(app)/asterisk/page.tsx new file mode 100644 index 0000000..3e1fefa --- /dev/null +++ b/apps/frontend/src/app/(app)/asterisk/page.tsx @@ -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(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 ( + <> + reload.mutate()}> + Reload + + ) + } + /> + +
+ + +
+

Conexão AMI (controle)

+

+ {statusLoading ? '...' : status?.amiControlConnection} +

+
+ + {status?.amiControlConnection ?? '—'} + +
+
+ + +
+

apps/asterisk-events (heartbeat)

+

{status?.lastHeartbeat ?? 'sem sinal'}

+
+ + {status?.asteriskEventsHeartbeat ?? '—'} + +
+
+
+ + + + Diagnóstico (comandos AMI allowlist) + + +
+ + +
+
+            {output ?? 'A saída do comando aparecerá aqui.'}
+          
+
+
+ +
+ +
+ + ); +} + +export default function AsteriskPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/auditoria/page.tsx b/apps/frontend/src/app/(app)/auditoria/page.tsx new file mode 100644 index 0000000..e5b3cf9 --- /dev/null +++ b/apps/frontend/src/app/(app)/auditoria/page.tsx @@ -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(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[] = [ + { 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) => ( + + ), + }, + ]; + + return ( + <> + + + +
+ + { setAction(e.target.value); setPage(1); }} /> +
+
+ + { setEntityType(e.target.value); setPage(1); }} /> +
+
+ + { setFrom(e.target.value); setPage(1); }} /> +
+
+ + { setTo(e.target.value); setPage(1); }} /> +
+
+
+ + 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." + /> + + !v && setDetail(null)}> + + + Detalhes do evento + + {detail && ( +
+

Ação: {detail.action}

+

Entidade: {detail.entityType ?? '—'} {detail.entityId ? `(${detail.entityId})` : ''}

+

Usuário: {detail.user?.email ?? detail.userId ?? '—'}

+

IP: {detail.ipAddress ?? '—'}

+

User agent: {detail.userAgent ?? '—'}

+
+

Antes

+
+                  {JSON.stringify(detail.before, null, 2) || '—'}
+                
+
+
+

Depois

+
+                  {JSON.stringify(detail.after, null, 2) || '—'}
+                
+
+
+ )} +
+
+ + ); +} + +export default function AuditPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/bloqueio/page.tsx b/apps/frontend/src/app/(app)/bloqueio/page.tsx new file mode 100644 index 0000000..393b758 --- /dev/null +++ b/apps/frontend/src/app/(app)/bloqueio/page.tsx @@ -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 ( + + + + Bloquear número + +
{ + e.preventDefault(); + mutation.mutate(); + }} + className="flex flex-col gap-4" + > +
+ + setPhone(e.target.value)} /> +
+
+ + setReason(e.target.value)} /> +
+ + + +
+
+
+ ); +} + +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(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[] = [ + { 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') ? ( + + ) : null, + }, + ]; + + return ( + <> + + { + const file = e.target.files?.[0]; + if (file) importCsv.mutate(file); + e.target.value = ''; + }} + /> + + + + ) + } + /> + 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." + /> + + + ); +} + +export default function SuppressionPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/campanhas/[id]/page.tsx b/apps/frontend/src/app/(app)/campanhas/[id]/page.tsx new file mode 100644 index 0000000..a2be4f7 --- /dev/null +++ b/apps/frontend/src/app/(app)/campanhas/[id]/page.tsx @@ -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 ( + + +

{label}

+

{value}

+
+
+ ); +} + +function CampaignDetailContent({ id }: { id: string }) { + const { can } = useAuth(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [cpsDraft, setCpsDraft] = React.useState(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 ; + } + + return ( + <> + + {can('campaigns.start') && + ['DRAFT', 'READY', 'PAUSED', 'STOPPED'].includes(campaign.status) && ( + + )} + {can('campaigns.pause') && campaign.status === 'RUNNING' && ( + + )} + {can('campaigns.stop') && ['RUNNING', 'PAUSED'].includes(campaign.status) && ( + + )} + + } + /> + +
+ + +
+ +
+ + + + + + + + + + + +
+ + + + Configuração + + +
+ + setCpsDraft(Number(e.target.value))} + /> +
+ {can('campaigns.update') && ( + + )} +
+

Concorrência máxima

+

{campaign.maxConcurrentCalls}

+
+
+

Janela de discagem

+

+ {campaign.startTime}–{campaign.endTime} ({campaign.timezone}) +

+
+
+

Máx. tentativas

+

{campaign.maxAttempts}

+
+
+ + AMD {campaign.amdEnabled ? 'ativo' : 'inativo'} + +
+
+
+ + ); +} + +export default function CampaignDetailPage() { + const params = useParams<{ id: string }>(); + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/campanhas/page.tsx b/apps/frontend/src/app/(app)/campanhas/page.tsx new file mode 100644 index 0000000..a65ed51 --- /dev/null +++ b/apps/frontend/src/app/(app)/campanhas/page.tsx @@ -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 = { + 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(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 ( + + + + Nova campanha + +
{ + e.preventDefault(); + mutation.mutate(); + }} + className="flex flex-col gap-4" + > +
+ + setForm((f) => ({ ...f, name: e.target.value }))} + /> +
+
+
+ + +
+
+ + +
+
+
+
+ + setForm((f) => ({ ...f, maxCps: Number(e.target.value) }))} + /> +
+
+ + + setForm((f) => ({ ...f, maxConcurrentCalls: Number(e.target.value) })) + } + /> +
+
+ + setForm((f) => ({ ...f, callerId: e.target.value }))} + /> +
+
+ + + +
+
+
+ ); +} + +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[] = [ + { + key: 'name', + header: 'Nome', + render: (r) => ( + + {r.name} + + ), + }, + { + key: 'status', + header: 'Status', + render: (r) => {r.status}, + }, + { 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) => ( +
+ {can('campaigns.start') && (r.status === 'DRAFT' || r.status === 'READY' || r.status === 'PAUSED' || r.status === 'STOPPED') && ( + + )} + {can('campaigns.pause') && r.status === 'RUNNING' && ( + + )} + {can('campaigns.stop') && (r.status === 'RUNNING' || r.status === 'PAUSED') && ( + + )} + + {can('campaigns.delete') && r.status === 'DRAFT' && ( + + )} +
+ ), + }, + ]; + + return ( + <> + setDialogOpen(true)}> + Nova campanha + + ) + } + /> + refetch()} + rowKey={(r) => r.id} + emptyMessage="Nenhuma campanha cadastrada." + /> + + + ); +} + +export default function CampaignsPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/compliance/page.tsx b/apps/frontend/src/app/(app)/compliance/page.tsx new file mode 100644 index 0000000..a898e5d --- /dev/null +++ b/apps/frontend/src/app/(app)/compliance/page.tsx @@ -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({}); + + 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 ( + + + Parâmetros de Compliance + + +
{ + e.preventDefault(); + mutation.mutate(); + }} + className="grid grid-cols-1 gap-4 sm:grid-cols-2" + > +
+ + + setForm((f) => ({ ...f, shortCallThresholdSeconds: Number(e.target.value) })) + } + /> +
+
+ + + setForm((f) => ({ ...f, maxAttemptsPerNumberPerDay: Number(e.target.value) })) + } + /> +
+
+ + + setForm((f) => ({ ...f, maxAttemptsPerNumberPerMonth: Number(e.target.value) })) + } + /> +
+
+ + + setForm((f) => ({ ...f, highVolumeMonthlyThreshold: Number(e.target.value) })) + } + /> +
+ {can('settings.manage') && ( +
+ +
+ )} +
+
+
+ ); +} + +function IndicatorsCard() { + const { data } = useQuery({ + queryKey: ['compliance-indicators'], + queryFn: complianceService.indicators, + refetchInterval: 30_000, + }); + + if (!data) return null; + + return ( + + + Indicadores (hoje / mês) + + +
+
+

Chamadas hoje

+

{data.totalCallsToday}

+
+
+

Atendidas hoje

+

{data.answeredCallsToday}

+
+
+

Chamadas curtas hoje

+

{data.shortCallsToday}

+
+
+

Abandonadas hoje

+

{data.abandonedCallsToday}

+
+
+

Chamadas no mês

+

{data.totalCallsMonth}

+
+
+ + {data.alerts.length > 0 && ( +
+ {data.alerts.map((a, i) => ( +
+ + {a} +
+ ))} +
+ )} + + {data.numbersOverDailyLimit.length > 0 && ( +
+

Números acima do limite diário

+
+ {data.numbersOverDailyLimit.map((n) => ( + + {n.phoneNormalized} ({n.attempts}) + + ))} +
+
+ )} +
+
+ ); +} + +export default function CompliancePage() { + return ( + + +
+ + +
+
+ ); +} diff --git a/apps/frontend/src/app/(app)/dialplan/page.tsx b/apps/frontend/src/app/(app)/dialplan/page.tsx new file mode 100644 index 0000000..16638a3 --- /dev/null +++ b/apps/frontend/src/app/(app)/dialplan/page.tsx @@ -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(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 ( + + + + {isEdit ? 'Editar entrada' : 'Nova entrada de dialplan'} + +
{ + e.preventDefault(); + mutation.mutate(); + }} + className="flex flex-col gap-4" + > +
+
+ + setForm((f) => ({ ...f, context: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, exten: e.target.value }))} + /> +
+
+
+
+ + setForm((f) => ({ ...f, priority: Number(e.target.value) }))} + /> +
+
+ + setForm((f) => ({ ...f, application: e.target.value }))} + /> +
+
+
+ + setForm((f) => ({ ...f, argument: e.target.value }))} + /> +
+ + + +
+
+
+ ); +} + +function EntriesTab() { + const { can } = useAuth(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [dialogOpen, setDialogOpen] = React.useState(false); + const [editing, setEditing] = React.useState(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[] = [ + { 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) => ( + + {r.enabled ? 'Ativa' : 'Inativa'} + + ), + }, + { + key: 'actions', + header: '', + className: 'text-right', + render: (r) => ( +
+ {can('dialplans.update') && ( + + )} + {can('dialplans.delete') && ( + + )} +
+ ), + }, + ]; + + return ( +
+
+ {can('dialplans.update') && ( + + )} + {can('dialplans.create') && ( + + )} +
+ refetch()} + rowKey={(r) => r.id} + emptyMessage="Nenhuma entrada de dialplan cadastrada." + /> + +
+ ); +} + +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[] = [ + { key: 'createdAt', header: 'Data', render: (r) => formatDateTime(r.createdAt) }, + { + key: 'status', + header: 'Status', + render: (r) => ( + + {r.status} + + ), + }, + { key: 'reloadResult', header: 'Resultado', render: (r) => r.reloadResult ?? '—' }, + { + key: 'actions', + header: '', + className: 'text-right', + render: (r) => + can('dialplans.update') && r.status === 'APPLIED' ? ( + + ) : null, + }, + ]; + + return ( + refetch()} + rowKey={(r) => r.id} + emptyMessage="Nenhuma versão publicada ainda." + /> + ); +} + +function DialplanContent() { + return ( + <> + + + + Entradas + Versões + + + + + + + + + + ); +} + +export default function DialplanPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/disposicoes/page.tsx b/apps/frontend/src/app/(app)/disposicoes/page.tsx new file mode 100644 index 0000000..d0182ec --- /dev/null +++ b/apps/frontend/src/app/(app)/disposicoes/page.tsx @@ -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 = { + 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(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 ( + + + + {isEdit ? 'Editar disposição' : 'Nova disposição'} + +
{ + e.preventDefault(); + mutation.mutate(); + }} + className="flex flex-col gap-4" + > +
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + /> +
+ {!isEdit && ( +
+ + setForm((f) => ({ ...f, code: e.target.value }))} + /> +
+ )} +
+
+ + +
+ + + +
+
+
+ ); +} + +function DispositionsContent() { + const { can } = useAuth(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [dialogOpen, setDialogOpen] = React.useState(false); + const [editing, setEditing] = React.useState(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[] = [ + { 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) => ( + {r.active ? 'Ativa' : 'Inativa'} + ), + }, + { + key: 'actions', + header: '', + className: 'text-right', + render: (r) => ( +
+ {can('settings.manage') && ( + <> + + + + )} +
+ ), + }, + ]; + + return ( + <> + { + setEditing(null); + setDialogOpen(true); + }} + > + Nova disposição + + ) + } + /> + refetch()} + rowKey={(r) => r.id} + emptyMessage="Nenhuma disposição cadastrada." + /> + + + ); +} + +export default function DispositionsPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/filas/page.tsx b/apps/frontend/src/app/(app)/filas/page.tsx new file mode 100644 index 0000000..4b02293 --- /dev/null +++ b/apps/frontend/src/app/(app)/filas/page.tsx @@ -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(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 ( + + + + {isEdit ? 'Editar fila' : 'Nova fila'} + +
{ + e.preventDefault(); + mutation.mutate(); + }} + className="flex flex-col gap-4" + > + {!isEdit && ( +
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, number: e.target.value }))} + /> +
+
+ )} +
+ + +
+
+
+ + setForm((f) => ({ ...f, timeout: Number(e.target.value) }))} + /> +
+
+ + setForm((f) => ({ ...f, wrapUpTime: Number(e.target.value) }))} + /> +
+
+ + + +
+
+
+ ); +} + +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 ( + + + + Agentes da fila {queue?.name} + +
+
+ + +
+
    + {(fullQueue?.members ?? []).map((m) => ( +
  • + + {m.agent?.name ?? m.agentId} + {m.penalty ? ` (penalidade ${m.penalty})` : ''} + + +
  • + ))} + {(fullQueue?.members ?? []).length === 0 && ( +

    + Nenhum agente associado. +

    + )} +
+
+
+
+ ); +} + +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(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[] = [ + { 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) => ( + + {r.enabled ? 'Ativa' : 'Inativa'} + + ), + }, + { + key: 'actions', + header: '', + className: 'text-right', + render: (r) => ( +
+ {can('queues.update') && ( + <> + + + + )} + {can('queues.delete') && ( + + )} +
+ ), + }, + ]; + + return ( + <> + { + setEditing(null); + setDialogOpen(true); + }} + > + Nova fila + + ) + } + /> + refetch()} + rowKey={(r) => r.id} + emptyMessage="Nenhuma fila cadastrada." + /> + + + + ); +} + +export default function QueuesPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/importacoes/page.tsx b/apps/frontend/src/app/(app)/importacoes/page.tsx new file mode 100644 index 0000000..5cd5784 --- /dev/null +++ b/apps/frontend/src/app/(app)/importacoes/page.tsx @@ -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(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[] = [ + { key: 'filename', header: 'Arquivo', render: (r) => r.filename }, + { + key: 'status', + header: 'Status', + render: (r) => {r.status}, + }, + { 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 ? ( + + ) : null, + }, + ]; + + return ( + <> + +
+ + {campaignId && can('campaigns.update') && ( + <> + { + const file = e.target.files?.[0]; + if (file) upload.mutate(file); + e.target.value = ''; + }} + /> + + + )} +
+ + {!campaignId ? ( +

+ Selecione uma campanha para ver o histórico de importações. +

+ ) : ( + + + refetch()} + rowKey={(r) => r.id} + emptyMessage="Nenhuma importação realizada ainda." + /> + + + )} + + ); +} + +export default function ImportsPage() { + return ( + + + + + + ); +} diff --git a/apps/frontend/src/app/(app)/layout.tsx b/apps/frontend/src/app/(app)/layout.tsx new file mode 100644 index 0000000..f64b136 --- /dev/null +++ b/apps/frontend/src/app/(app)/layout.tsx @@ -0,0 +1,5 @@ +import { AppShell } from '@/components/layout/app-shell'; + +export default function AppGroupLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/frontend/src/app/(app)/leads/page.tsx b/apps/frontend/src/app/(app)/leads/page.tsx new file mode 100644 index 0000000..339f84d --- /dev/null +++ b/apps/frontend/src/app/(app)/leads/page.tsx @@ -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 = { + 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('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[] = [ + { key: 'name', header: 'Nome', render: (r) => r.name ?? '—' }, + { key: 'phone', header: 'Telefone', render: (r) => r.phone }, + { + key: 'status', + header: 'Status', + render: (r) => {r.status}, + }, + { 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 ( + <> + +
+ { + setCampaignId(v); + setPage(1); + }} + /> + +
+ + {!campaignId ? ( +

+ Selecione uma campanha para ver os leads. +

+ ) : ( + 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 ( + + + + + + ); +} diff --git a/apps/frontend/src/app/(app)/monitoramento/agentes/page.tsx b/apps/frontend/src/app/(app)/monitoramento/agentes/page.tsx new file mode 100644 index 0000000..acbcf96 --- /dev/null +++ b/apps/frontend/src/app/(app)/monitoramento/agentes/page.tsx @@ -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 = { + AVAILABLE: 'success', + IN_CALL: 'warning', + RINGING: 'warning', + WRAP_UP: 'warning', + PAUSED: 'destructive', + LOGGED_IN: 'secondary', + LOGGED_OUT: 'outline', + OFFLINE: 'outline', +}; + +const STATE_LABEL: Record = { + 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[] = [ + { 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) => ( +
+ {r.queues.map((q) => ( + + {q} + + ))} +
+ ), + }, + { + key: 'state', + header: 'Estado', + render: (r) => {STATE_LABEL[r.state]}, + }, + { key: 'stateSince', header: 'Desde', render: (r) => formatDateTime(r.stateSince) }, + ]; + + return ( + <> + + refetch()} + rowKey={(r) => r.id} + emptyMessage="Nenhum agente ativo." + /> + + ); +} + +export default function MonitoringAgentsPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/monitoramento/campanhas/page.tsx b/apps/frontend/src/app/(app)/monitoramento/campanhas/page.tsx new file mode 100644 index 0000000..66586bc --- /dev/null +++ b/apps/frontend/src/app/(app)/monitoramento/campanhas/page.tsx @@ -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 ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ); + } + + if (active.length === 0) { + return ( +

+ Nenhuma campanha em execução, pausada ou drenando no momento. +

+ ); + } + + return ( +
+ {active.map((campaign, idx) => { + const live = liveQueries[idx]?.data; + return ( + + + + + {campaign.name} + + + {campaign.status} + + + + +
+

CPS atual

+

{live?.cpsAtual ?? '—'} / {campaign.maxCps}

+
+
+

Conectadas

+

{live?.connected ?? '—'}

+
+
+

Leads restantes

+

{live?.leadsRemaining ?? '—'}

+
+
+

Abandono

+

{formatPercent(live?.abandonRate ?? undefined)}

+
+
+

TMA médio

+

{formatSeconds(live?.avgTalkTimeSeconds ?? undefined)}

+
+
+

Pacing

+

{live?.pacingFactor?.toFixed(2) ?? '—'}

+
+
+
+ ); + })} +
+ ); +} + +export default function MonitoringCampaignsPage() { + return ( + + + + + ); +} diff --git a/apps/frontend/src/app/(app)/monitoramento/filas/page.tsx b/apps/frontend/src/app/(app)/monitoramento/filas/page.tsx new file mode 100644 index 0000000..f1f68d2 --- /dev/null +++ b/apps/frontend/src/app/(app)/monitoramento/filas/page.tsx @@ -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[] = [ + { key: 'name', header: 'Fila', render: (r) => r.name }, + { key: 'strategy', header: 'Estratégia', render: (r) => r.strategy }, + { + key: 'callsWaiting', + header: 'Aguardando', + render: (r) => ( + 0 ? 'warning' : 'secondary'}>{r.callsWaiting} + ), + }, + { + 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 ( + <> + + refetch()} + rowKey={(r) => r.id} + emptyMessage="Nenhuma fila ativa." + /> + + ); +} + +export default function MonitoringQueuesPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/monitoramento/ramais/page.tsx b/apps/frontend/src/app/(app)/monitoramento/ramais/page.tsx new file mode 100644 index 0000000..7cf210a --- /dev/null +++ b/apps/frontend/src/app/(app)/monitoramento/ramais/page.tsx @@ -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 = { + online: 'success', + busy: 'warning', + offline: 'destructive', + unknown: 'secondary', +}; + +const STATUS_LABEL: Record = { + 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[] = [ + { key: 'number', header: 'Ramal', render: (r) => r.number }, + { key: 'name', header: 'Nome', render: (r) => r.name }, + { + key: 'status', + header: 'Status', + render: (r) => ( + {STATUS_LABEL[r.status] ?? r.status} + ), + }, + { 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 ( + <> + + refetch()} + rowKey={(r) => r.number} + emptyMessage="Nenhum ramal cadastrado." + /> + + ); +} + +export default function MonitoringExtensionsPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/page.tsx b/apps/frontend/src/app/(app)/page.tsx new file mode 100644 index 0000000..e91f4aa --- /dev/null +++ b/apps/frontend/src/app/(app)/page.tsx @@ -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 ( + + + + + +
+

{label}

+ {isLoading ? ( + + ) : ( +

{value}

+ )} +
+
+
+ ); +} + +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 ( +
+
+ + + + +
+ +
+ + + + +
+ + + + Chamadas por hora (hoje) + + + {callsByHour.isLoading ? ( + + ) : callsByHour.data && callsByHour.data.length > 0 ? ( + + + + `${h}h`} fontSize={12} /> + + `${h}h`} + contentStyle={{ fontSize: 12 }} + /> + + + + + ) : ( +

+ Nenhuma chamada registrada hoje ainda. +

+ )} +
+
+
+ ); +} + +export default function DashboardPage() { + return ( + + + + + ); +} diff --git a/apps/frontend/src/app/(app)/pausas/page.tsx b/apps/frontend/src/app/(app)/pausas/page.tsx new file mode 100644 index 0000000..ae7f026 --- /dev/null +++ b/apps/frontend/src/app/(app)/pausas/page.tsx @@ -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(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 ( + + + + {isEdit ? 'Editar motivo de pausa' : 'Novo motivo de pausa'} + +
{ + e.preventDefault(); + mutation.mutate(); + }} + className="flex flex-col gap-4" + > +
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + /> +
+ {!isEdit && ( +
+ + setForm((f) => ({ ...f, code: e.target.value }))} + /> +
+ )} +
+
+ + + setForm((f) => ({ + ...f, + maxDurationSeconds: e.target.value ? Number(e.target.value) : undefined, + })) + } + /> +
+
+ +
+ + + +
+
+
+ ); +} + +function PauseReasonsContent() { + const { can } = useAuth(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [dialogOpen, setDialogOpen] = React.useState(false); + const [editing, setEditing] = React.useState(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[] = [ + { 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) => {r.paid ? 'Sim' : 'Não'}, + }, + { + key: 'active', + header: 'Status', + render: (r) => ( + {r.active ? 'Ativo' : 'Inativo'} + ), + }, + { + key: 'actions', + header: '', + className: 'text-right', + render: (r) => ( +
+ {can('settings.manage') && ( + <> + + + + )} +
+ ), + }, + ]; + + return ( + <> + { + setEditing(null); + setDialogOpen(true); + }} + > + Novo motivo + + ) + } + /> + refetch()} + rowKey={(r) => r.id} + emptyMessage="Nenhum motivo de pausa cadastrado." + /> + + + ); +} + +export default function PauseReasonsPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/perfis/page.tsx b/apps/frontend/src/app/(app)/perfis/page.tsx new file mode 100644 index 0000000..bd0763a --- /dev/null +++ b/apps/frontend/src/app/(app)/perfis/page.tsx @@ -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 { + const groups: Record = {}; + 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([]); + + 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 ( + + + + {isEdit ? 'Editar perfil' : 'Novo perfil'} + +
{ + e.preventDefault(); + mutation.mutate(); + }} + className="flex flex-col gap-4" + > +
+
+ + setName(e.target.value)} + /> +
+
+ + setDescription(e.target.value)} /> +
+
+ +
+ +
+ {Object.entries(groups).map(([group, perms]) => ( +
+

{group}

+ {perms.map((p) => ( + + ))} +
+ ))} +
+
+ + + + +
+
+
+ ); +} + +function Content() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [dialogOpen, setDialogOpen] = React.useState(false); + const [editing, setEditing] = React.useState(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[] = [ + { 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) => {r.isSystem ? 'Sistema' : 'Customizado'}, + }, + { + key: 'actions', + header: '', + className: 'text-right', + render: (r) => ( +
+ + {!r.isSystem && ( + + )} +
+ ), + }, + ]; + + return ( + <> + { setEditing(null); setDialogOpen(true); }}> + Novo perfil + + } + /> + refetch()} + rowKey={(r) => r.id} + emptyMessage="Nenhum perfil cadastrado." + /> + + + ); +} + +export default function RolesPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/ramais/page.tsx b/apps/frontend/src/app/(app)/ramais/page.tsx new file mode 100644 index 0000000..c7ef453 --- /dev/null +++ b/apps/frontend/src/app/(app)/ramais/page.tsx @@ -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({ + number: '', + name: '', + callerId: '', + enabled: true, + }); + const [createdPassword, setCreatedPassword] = React.useState(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 ( + + + + {isEdit ? 'Editar ramal' : 'Novo ramal'} + + + {createdPassword ? ( +
+

+ Senha SIP gerada (exibida apenas uma vez, copie agora): +

+
+ {createdPassword} + +
+ + + +
+ ) : ( +
{ + e.preventDefault(); + mutation.mutate(); + }} + className="flex flex-col gap-4" + > + {!isEdit && ( +
+ + setForm((f) => ({ ...f, number: e.target.value }))} + /> +
+ )} +
+ + setForm((f) => ({ ...f, name: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, callerId: e.target.value }))} + /> +
+ + + +
+ )} +
+
+ ); +} + +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(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[] = [ + { 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) => ( + + {r.enabled ? 'Ativo' : 'Inativo'} + + ), + }, + { key: 'updatedAt', header: 'Atualizado em', render: (r) => formatDateTime(r.updatedAt) }, + { + key: 'actions', + header: '', + className: 'text-right', + render: (r) => ( +
+ {can('extensions.update') && ( + <> + + + + )} + {can('extensions.delete') && ( + + )} +
+ ), + }, + ]; + + return ( + <> + { + setEditing(null); + setDialogOpen(true); + }} + > + Novo ramal + + ) + } + /> + refetch()} + rowKey={(r) => r.id} + searchValue={search} + onSearchChange={setSearch} + searchPlaceholder="Buscar por número ou nome..." + emptyMessage="Nenhum ramal cadastrado." + /> + + + ); +} + +export default function ExtensionsPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/relatorios/agentes/page.tsx b/apps/frontend/src/app/(app)/relatorios/agentes/page.tsx new file mode 100644 index 0000000..16fff9a --- /dev/null +++ b/apps/frontend/src/app/(app)/relatorios/agentes/page.tsx @@ -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 = { + 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 ( + <> + + + +
+ + +
+
+ + setFrom(e.target.value)} /> +
+
+ + setTo(e.target.value)} /> +
+
+
+ + {!agentId ? ( +

Selecione um agente.

+ ) : isLoading ? ( + + ) : report ? ( +
+
+ + +

Chamadas atendidas

+

{report.callsAnswered}

+
+
+ + +

TMA médio

+

{formatSeconds(report.tmaSeconds)}

+
+
+ {Object.entries(report.timeByStateSeconds).map(([state, seconds]) => ( + + +

{STATE_LABEL[state] ?? state}

+

{formatSeconds(seconds)}

+
+
+ ))} +
+ + + + Pausas no período + + + + + + Motivo + Início + Fim + Duração + + + + {report.pauses.map((p, i) => ( + + {p.reason} + {new Date(p.startedAt).toLocaleString('pt-BR')} + {p.endedAt ? new Date(p.endedAt).toLocaleString('pt-BR') : 'Em andamento'} + {formatSeconds(p.durationSeconds)} + + ))} + {report.pauses.length === 0 && ( + + + Nenhuma pausa no período. + + + )} + +
+
+
+
+ ) : null} + + ); +} + +export default function AgentReportPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/relatorios/chamadas/page.tsx b/apps/frontend/src/app/(app)/relatorios/chamadas/page.tsx new file mode 100644 index 0000000..de84f34 --- /dev/null +++ b/apps/frontend/src/app/(app)/relatorios/chamadas/page.tsx @@ -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 ( + + +

{label}

+

{value}

+
+
+ ); +} + +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[] = [ + { 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) => {r.state} }, + { key: 'hangupCause', header: 'Causa', render: (r) => r.hangupCause ?? '—' }, + { key: 'disposition', header: 'Disposição', render: (r) => r.disposition?.name ?? '—' }, + ]; + + return ( + <> + exportMutation.mutate()}> + Exportar CSV + + ) + } + /> + +
+ + + + +
+ + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + setFilters((f) => ({ ...f, phone: e.target.value }))} /> +
+
+ +
+
+ + setFilters((f) => ({ ...f, from: e.target.value }))} /> +
+
+ + setFilters((f) => ({ ...f, to: e.target.value }))} /> +
+
+
+ + 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 ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/troncos/page.tsx b/apps/frontend/src/app/(app)/troncos/page.tsx new file mode 100644 index 0000000..04b46be --- /dev/null +++ b/apps/frontend/src/app/(app)/troncos/page.tsx @@ -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(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 ( + + + + {isEdit ? 'Editar tronco' : 'Novo tronco'} + +
{ + e.preventDefault(); + mutation.mutate(); + }} + className="flex flex-col gap-4" + > + {!isEdit && ( +
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + /> +
+
+ + +
+
+ )} +
+
+ + setForm((f) => ({ ...f, host: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, port: Number(e.target.value) }))} + /> +
+
+
+
+ + setForm((f) => ({ ...f, username: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, password: e.target.value }))} + /> +
+
+
+
+ + setForm((f) => ({ ...f, callerId: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, maxCps: Number(e.target.value) }))} + /> +
+
+ + + +
+
+
+ ); +} + +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(null); + const [statusById, setStatusById] = React.useState>({}); + + 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[] = [ + { 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] ? ( + + {statusById[r.id]} + + ) : ( + + {r.enabled ? 'Ativo' : 'Inativo'} + + ), + }, + { + key: 'actions', + header: '', + className: 'text-right', + render: (r) => ( +
+ + {can('trunks.update') && ( + + )} + {can('trunks.delete') && ( + + )} +
+ ), + }, + ]; + + return ( + <> + { + setEditing(null); + setDialogOpen(true); + }} + > + Novo tronco + + ) + } + /> + refetch()} + rowKey={(r) => r.id} + searchValue={search} + onSearchChange={setSearch} + searchPlaceholder="Buscar por nome ou host..." + emptyMessage="Nenhum tronco cadastrado." + /> + + + ); +} + +export default function TrunksPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/(app)/usuarios/page.tsx b/apps/frontend/src/app/(app)/usuarios/page.tsx new file mode 100644 index 0000000..c43239b --- /dev/null +++ b/apps/frontend/src/app/(app)/usuarios/page.tsx @@ -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([]); + + 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 ( + + + + {isEdit ? 'Editar usuário' : 'Novo usuário'} + +
{ + e.preventDefault(); + mutation.mutate(); + }} + className="flex flex-col gap-4" + > +
+ + setName(e.target.value)} /> +
+ {!isEdit && ( +
+ + setEmail(e.target.value)} + /> +
+ )} + {isEdit && ( +
+ + +
+ )} +
+ +
+ {(roles ?? []).map((r) => ( + + ))} +
+
+ + + +
+
+
+ ); +} + +function Content() { + const { can } = useAuth(); + const [dialogOpen, setDialogOpen] = React.useState(false); + const [editing, setEditing] = React.useState(null); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['users'], + queryFn: usersService.list, + }); + + const columns: DataTableColumn[] = [ + { key: 'name', header: 'Nome', render: (r) => r.name }, + { key: 'email', header: 'E-mail', render: (r) => r.email }, + { + key: 'roles', + header: 'Perfis', + render: (r) => ( +
+ {r.roles.map((role) => ( + {role.name} + ))} +
+ ), + }, + { + key: 'isActive', + header: 'Status', + render: (r) => {r.isActive ? 'Ativo' : 'Inativo'}, + }, + { key: 'lastLoginAt', header: 'Último login', render: (r) => formatDateTime(r.lastLoginAt) }, + { + key: 'actions', + header: '', + className: 'text-right', + render: (r) => + can('users.update') ? ( + + ) : null, + }, + ]; + + return ( + <> + { setEditing(null); setDialogOpen(true); }}> + Novo usuário + + ) + } + /> + refetch()} + rowKey={(r) => r.id} + emptyMessage="Nenhum usuário cadastrado." + /> + + + ); +} + +export default function UsersPage() { + return ( + + + + ); +} diff --git a/apps/frontend/src/app/globals.css b/apps/frontend/src/app/globals.css new file mode 100644 index 0000000..a31d03c --- /dev/null +++ b/apps/frontend/src/app/globals.css @@ -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; +} diff --git a/apps/frontend/src/app/icon.png b/apps/frontend/src/app/icon.png new file mode 100644 index 0000000..37ba6e8 Binary files /dev/null and b/apps/frontend/src/app/icon.png differ diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx new file mode 100644 index 0000000..ae0715b --- /dev/null +++ b/apps/frontend/src/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/apps/frontend/src/app/login/page.tsx b/apps/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..f07eb1c --- /dev/null +++ b/apps/frontend/src/app/login/page.tsx @@ -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 ( + + + + ); +} + +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(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 ( +
+ + + B2BCall + B2BCall + Entre com suas credenciais para continuar + + +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ {error &&

{error}

} + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/app/trocar-senha/page.tsx b/apps/frontend/src/app/trocar-senha/page.tsx new file mode 100644 index 0000000..ffd3042 --- /dev/null +++ b/apps/frontend/src/app/trocar-senha/page.tsx @@ -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(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 ( +
+ + + B2BCall + Troca de senha obrigatória + + Defina uma nova senha para continuar utilizando o sistema. + + + +
+
+ + setCurrent(e.target.value)} + /> +
+
+ + setNext(e.target.value)} + /> +
+
+ + setConfirm(e.target.value)} + /> +
+ {error &&

{error}

} + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/campaign-select.tsx b/apps/frontend/src/components/campaign-select.tsx new file mode 100644 index 0000000..4d53d10 --- /dev/null +++ b/apps/frontend/src/components/campaign-select.tsx @@ -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 ( + + ); +} diff --git a/apps/frontend/src/components/data-table/data-table.tsx b/apps/frontend/src/components/data-table/data-table.tsx new file mode 100644 index 0000000..6722bd4 --- /dev/null +++ b/apps/frontend/src/components/data-table/data-table.tsx @@ -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 { + key: string; + header: string; + render: (row: T) => React.ReactNode; + className?: string; +} + +interface DataTableProps { + columns: DataTableColumn[]; + 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({ + 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) { + const showPagination = + page !== undefined && pageSize !== undefined && total !== undefined; + const totalPages = showPagination ? Math.max(1, Math.ceil(total / pageSize)) : 1; + + return ( +
+ {(onSearchChange || toolbar) && ( +
+ {onSearchChange && ( +
+ + onSearchChange(e.target.value)} + placeholder={searchPlaceholder} + className="pl-8" + /> +
+ )} +
{toolbar}
+
+ )} + + + + + {columns.map((col) => ( + + {col.header} + + ))} + + + + {isLoading && + Array.from({ length: 5 }).map((_, i) => ( + + {columns.map((col) => ( + + + + ))} + + ))} + + {!isLoading && isError && ( + + +
+ +

{errorMessage}

+ {onRetry && ( + + )} +
+
+
+ )} + + {!isLoading && !isError && (!data || data.length === 0) && ( + + +
+ +

{emptyMessage}

+
+
+
+ )} + + {!isLoading && + !isError && + data?.map((row) => ( + onRowClick?.(row)} + className={cn(onRowClick && 'cursor-pointer')} + > + {columns.map((col) => ( + + {col.render(row)} + + ))} + + ))} +
+
+ + {showPagination && total > 0 && ( +
+ + {total} registro{total === 1 ? '' : 's'} — página {page} de {totalPages} + +
+ + +
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/components/layout/app-shell.tsx b/apps/frontend/src/components/layout/app-shell.tsx new file mode 100644 index 0000000..dade3cc --- /dev/null +++ b/apps/frontend/src/components/layout/app-shell.tsx @@ -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 ( +
+ +
+ ); + } + + return ( +
+
+ +
+ + {mobileOpen && ( +
+
setMobileOpen(false)} + /> + +
+ )} + +
+ setMobileOpen(true)} /> +
{children}
+
+
+ ); +} diff --git a/apps/frontend/src/components/layout/nav-config.ts b/apps/frontend/src/components/layout/nav-config.ts new file mode 100644 index 0000000..c86c894 --- /dev/null +++ b/apps/frontend/src/components/layout/nav-config.ts @@ -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; diff --git a/apps/frontend/src/components/layout/page-header.tsx b/apps/frontend/src/components/layout/page-header.tsx new file mode 100644 index 0000000..136cd84 --- /dev/null +++ b/apps/frontend/src/components/layout/page-header.tsx @@ -0,0 +1,19 @@ +export function PageHeader({ + title, + description, + actions, +}: { + title: string; + description?: string; + actions?: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/apps/frontend/src/components/layout/sidebar.tsx b/apps/frontend/src/components/layout/sidebar.tsx new file mode 100644 index 0000000..0eca782 --- /dev/null +++ b/apps/frontend/src/components/layout/sidebar.tsx @@ -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>({}); + + React.useEffect(() => { + const initial: Record = {}; + 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 ( + + ); +} diff --git a/apps/frontend/src/components/layout/topbar.tsx b/apps/frontend/src/components/layout/topbar.tsx new file mode 100644 index 0000000..fef470e --- /dev/null +++ b/apps/frontend/src/components/layout/topbar.tsx @@ -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 ( +
+ + +
+ + + + + + + + + {user?.email} + + logout()} className="text-destructive"> + + Sair + + + +
+ ); +} diff --git a/apps/frontend/src/components/providers.tsx b/apps/frontend/src/components/providers.tsx new file mode 100644 index 0000000..58ecabd --- /dev/null +++ b/apps/frontend/src/components/providers.tsx @@ -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 ( + + + + + {children} + + + + + ); +} diff --git a/apps/frontend/src/components/require-permission.tsx b/apps/frontend/src/components/require-permission.tsx new file mode 100644 index 0000000..7595bb2 --- /dev/null +++ b/apps/frontend/src/components/require-permission.tsx @@ -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 ( +
+ +

Você não tem permissão para acessar esta tela.

+
+ ); + } + return <>{children}; +} diff --git a/apps/frontend/src/components/ui/badge.tsx b/apps/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..6f81eab --- /dev/null +++ b/apps/frontend/src/components/ui/badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/apps/frontend/src/components/ui/button.tsx b/apps/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..9f00109 --- /dev/null +++ b/apps/frontend/src/components/ui/button.tsx @@ -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, + VariantProps { + asChild?: boolean; + loading?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild, loading, children, disabled, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + + {loading && } + {children} + + ); + }, +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/apps/frontend/src/components/ui/card.tsx b/apps/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..2202ac4 --- /dev/null +++ b/apps/frontend/src/components/ui/card.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> +>(({ className, ...props }, ref) => ( +
+)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }; diff --git a/apps/frontend/src/components/ui/checkbox.tsx b/apps/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..21ff3c6 --- /dev/null +++ b/apps/frontend/src/components/ui/checkbox.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/apps/frontend/src/components/ui/dialog.tsx b/apps/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..b2ea6a5 --- /dev/null +++ b/apps/frontend/src/components/ui/dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Fechar + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.ComponentProps<'div'>) => ( +
+); + +const DialogFooter = ({ className, ...props }: React.ComponentProps<'div'>) => ( +
+); + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/frontend/src/components/ui/dropdown-menu.tsx b/apps/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..56f2179 --- /dev/null +++ b/apps/frontend/src/components/ui/dropdown-menu.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuRadioGroup, +}; diff --git a/apps/frontend/src/components/ui/input.tsx b/apps/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..fb070c0 --- /dev/null +++ b/apps/frontend/src/components/ui/input.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => ( + + ), +); +Input.displayName = 'Input'; + +const Textarea = React.forwardRef< + HTMLTextAreaElement, + React.ComponentProps<'textarea'> +>(({ className, ...props }, ref) => ( +