+
+
+
+
+ {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) => (
+
+));
+Textarea.displayName = 'Textarea';
+
+export { Input, Textarea };
diff --git a/apps/frontend/src/components/ui/label.tsx b/apps/frontend/src/components/ui/label.tsx
new file mode 100644
index 0000000..be596f8
--- /dev/null
+++ b/apps/frontend/src/components/ui/label.tsx
@@ -0,0 +1,22 @@
+'use client';
+
+import * as React from 'react';
+import * as LabelPrimitive from '@radix-ui/react-label';
+import { cn } from '@/lib/utils';
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+
+export { Label };
diff --git a/apps/frontend/src/components/ui/select.tsx b/apps/frontend/src/components/ui/select.tsx
new file mode 100644
index 0000000..9d16d9d
--- /dev/null
+++ b/apps/frontend/src/components/ui/select.tsx
@@ -0,0 +1,77 @@
+'use client';
+
+import * as React from 'react';
+import * as SelectPrimitive from '@radix-ui/react-select';
+import { Check, ChevronDown } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+const Select = SelectPrimitive.Root;
+const SelectValue = SelectPrimitive.Value;
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1',
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = 'popper', ...props }, ref) => (
+
+
+
+ {children}
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem };
diff --git a/apps/frontend/src/components/ui/skeleton.tsx b/apps/frontend/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..0a14936
--- /dev/null
+++ b/apps/frontend/src/components/ui/skeleton.tsx
@@ -0,0 +1,12 @@
+import { cn } from '@/lib/utils';
+
+function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/apps/frontend/src/components/ui/switch.tsx b/apps/frontend/src/components/ui/switch.tsx
new file mode 100644
index 0000000..08512a4
--- /dev/null
+++ b/apps/frontend/src/components/ui/switch.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import * as React from 'react';
+import * as SwitchPrimitive from '@radix-ui/react-switch';
+import { cn } from '@/lib/utils';
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+Switch.displayName = SwitchPrimitive.Root.displayName;
+
+export { Switch };
diff --git a/apps/frontend/src/components/ui/table.tsx b/apps/frontend/src/components/ui/table.tsx
new file mode 100644
index 0000000..b296d97
--- /dev/null
+++ b/apps/frontend/src/components/ui/table.tsx
@@ -0,0 +1,66 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+const Table = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+Table.displayName = 'Table';
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.ComponentProps<'thead'>
+>(({ className, ...props }, ref) => (
+
+));
+TableHeader.displayName = 'TableHeader';
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.ComponentProps<'tbody'>
+>(({ className, ...props }, ref) => (
+
+));
+TableBody.displayName = 'TableBody';
+
+const TableRow = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+TableRow.displayName = 'TableRow';
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ComponentProps<'th'>
+>(({ className, ...props }, ref) => (
+ |
+));
+TableHead.displayName = 'TableHead';
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.ComponentProps<'td'>
+>(({ className, ...props }, ref) => (
+ |
+));
+TableCell.displayName = 'TableCell';
+
+export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
diff --git a/apps/frontend/src/components/ui/tabs.tsx b/apps/frontend/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..03c1b65
--- /dev/null
+++ b/apps/frontend/src/components/ui/tabs.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import * as React from 'react';
+import * as TabsPrimitive from '@radix-ui/react-tabs';
+import { cn } from '@/lib/utils';
+
+const Tabs = TabsPrimitive.Root;
+
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsList.displayName = TabsPrimitive.List.displayName;
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsContent.displayName = TabsPrimitive.Content.displayName;
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/apps/frontend/src/components/ui/toast.tsx b/apps/frontend/src/components/ui/toast.tsx
new file mode 100644
index 0000000..34edaf4
--- /dev/null
+++ b/apps/frontend/src/components/ui/toast.tsx
@@ -0,0 +1,90 @@
+'use client';
+
+import * as React from 'react';
+import * as ToastPrimitive from '@radix-ui/react-toast';
+import { CheckCircle2, XCircle, Info, X } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+export interface ToastMessage {
+ id: number;
+ title: string;
+ description?: string;
+ variant?: 'default' | 'success' | 'destructive';
+}
+
+interface ToastContextValue {
+ toast: (message: Omit) => void;
+}
+
+const ToastContext = React.createContext(null);
+
+export function useToast() {
+ const ctx = React.useContext(ToastContext);
+ if (!ctx) throw new Error('useToast deve ser usado dentro de ToastProvider');
+ return ctx;
+}
+
+const icons = {
+ default: Info,
+ success: CheckCircle2,
+ destructive: XCircle,
+};
+
+export function ToastProvider({ children }: { children: React.ReactNode }) {
+ const [messages, setMessages] = React.useState([]);
+ const idRef = React.useRef(0);
+
+ const toast = React.useCallback((message: Omit) => {
+ idRef.current += 1;
+ const id = idRef.current;
+ setMessages((prev) => [...prev, { ...message, id }]);
+ }, []);
+
+ const remove = (id: number) =>
+ setMessages((prev) => prev.filter((m) => m.id !== id));
+
+ return (
+
+
+ {children}
+ {messages.map((m) => {
+ const Icon = icons[m.variant ?? 'default'];
+ return (
+ !open && remove(m.id)}
+ className={cn(
+ 'flex items-start gap-3 rounded-lg border p-4 shadow-lg data-[state=open]:animate-none',
+ 'bg-card text-card-foreground border-border',
+ m.variant === 'destructive' && 'border-destructive/40 bg-destructive/10',
+ m.variant === 'success' && 'border-success/40 bg-success/10',
+ )}
+ >
+
+
+
+ {m.title}
+
+ {m.description && (
+
+ {m.description}
+
+ )}
+
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/frontend/src/components/ui/tooltip.tsx b/apps/frontend/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..51864ee
--- /dev/null
+++ b/apps/frontend/src/components/ui/tooltip.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import * as React from 'react';
+import * as TooltipPrimitive from '@radix-ui/react-tooltip';
+import { cn } from '@/lib/utils';
+
+const TooltipProvider = TooltipPrimitive.Provider;
+const Tooltip = TooltipPrimitive.Root;
+const TooltipTrigger = TooltipPrimitive.Trigger;
+
+const TooltipContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 6, ...props }, ref) => (
+
+));
+TooltipContent.displayName = TooltipPrimitive.Content.displayName;
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/apps/frontend/src/hooks/use-auth.tsx b/apps/frontend/src/hooks/use-auth.tsx
new file mode 100644
index 0000000..7c8ccca
--- /dev/null
+++ b/apps/frontend/src/hooks/use-auth.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import * as React from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { authService } from '@/services/auth';
+import { ApiError } from '@/lib/api-client';
+import type { CurrentUser } from '@/types';
+import type { Permission } from '@/lib/permissions';
+import { hasPermission, hasAnyPermission } from '@/lib/permissions';
+
+interface AuthContextValue {
+ user: CurrentUser | undefined;
+ isLoading: boolean;
+ isAuthenticated: boolean;
+ can: (permission: Permission | Permission[]) => boolean;
+ canAny: (permissions: Permission[]) => boolean;
+ refetch: () => void;
+}
+
+const AuthContext = React.createContext(null);
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ['auth', 'me'],
+ queryFn: async () => {
+ try {
+ return await authService.me();
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 401) return null;
+ throw err;
+ }
+ },
+ retry: false,
+ staleTime: 60_000,
+ });
+
+ const value: AuthContextValue = {
+ user: data ?? undefined,
+ isLoading,
+ isAuthenticated: Boolean(data),
+ can: (permission) => hasPermission(data?.permissions, permission),
+ canAny: (permissions) => hasAnyPermission(data?.permissions, permissions),
+ refetch,
+ };
+
+ return {children};
+}
+
+export function useAuth() {
+ const ctx = React.useContext(AuthContext);
+ if (!ctx) throw new Error('useAuth deve ser usado dentro de AuthProvider');
+ return ctx;
+}
+
+export function useLogout() {
+ const queryClient = useQueryClient();
+ return async () => {
+ await authService.logout();
+ queryClient.clear();
+ window.location.href = '/login';
+ };
+}
diff --git a/apps/frontend/src/hooks/use-debounce.ts b/apps/frontend/src/hooks/use-debounce.ts
new file mode 100644
index 0000000..8afcff9
--- /dev/null
+++ b/apps/frontend/src/hooks/use-debounce.ts
@@ -0,0 +1,12 @@
+import { useEffect, useState } from 'react';
+
+export function useDebounce(value: T, delayMs = 300): T {
+ const [debounced, setDebounced] = useState(value);
+
+ useEffect(() => {
+ const handle = setTimeout(() => setDebounced(value), delayMs);
+ return () => clearTimeout(handle);
+ }, [value, delayMs]);
+
+ return debounced;
+}
diff --git a/apps/frontend/src/hooks/use-theme.tsx b/apps/frontend/src/hooks/use-theme.tsx
new file mode 100644
index 0000000..68f56f3
--- /dev/null
+++ b/apps/frontend/src/hooks/use-theme.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import * as React from 'react';
+
+type Theme = 'light' | 'dark';
+
+interface ThemeContextValue {
+ theme: Theme;
+ toggle: () => void;
+}
+
+const ThemeContext = React.createContext(null);
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+ const [theme, setTheme] = React.useState('light');
+
+ React.useEffect(() => {
+ const stored = window.localStorage.getItem('b2bcall-theme') as Theme | null;
+ const initial =
+ stored ??
+ (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
+ setTheme(initial);
+ }, []);
+
+ React.useEffect(() => {
+ document.documentElement.classList.toggle('dark', theme === 'dark');
+ window.localStorage.setItem('b2bcall-theme', theme);
+ }, [theme]);
+
+ const toggle = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useTheme() {
+ const ctx = React.useContext(ThemeContext);
+ if (!ctx) throw new Error('useTheme deve ser usado dentro de ThemeProvider');
+ return ctx;
+}
diff --git a/apps/frontend/src/lib/api-client.ts b/apps/frontend/src/lib/api-client.ts
new file mode 100644
index 0000000..50c7b12
--- /dev/null
+++ b/apps/frontend/src/lib/api-client.ts
@@ -0,0 +1,127 @@
+// Padrão relativo: em produção o Nginx expõe frontend e API na mesma
+// origem (agente.md seção 87), eliminando CORS e problemas de cookie
+// cross-origin. Só sobrescrever via env em cenários de desenvolvimento
+// onde a API roda em host/porta diferentes do frontend.
+const API_URL = process.env.NEXT_PUBLIC_API_URL ?? '/api';
+
+export class ApiError extends Error {
+ status: number;
+ details: unknown;
+
+ constructor(status: number, message: string, details?: unknown) {
+ super(message);
+ this.status = status;
+ this.details = details;
+ }
+}
+
+let refreshPromise: Promise | null = null;
+
+async function tryRefresh(): Promise {
+ if (!refreshPromise) {
+ refreshPromise = fetch(`${API_URL}/auth/refresh`, {
+ method: 'POST',
+ credentials: 'include',
+ })
+ .then((res) => res.ok)
+ .catch(() => false)
+ .finally(() => {
+ refreshPromise = null;
+ });
+ }
+ return refreshPromise;
+}
+
+function buildQuery(params?: object): string {
+ if (!params) return '';
+ const search = new URLSearchParams();
+ for (const [key, value] of Object.entries(params as Record)) {
+ if (value === undefined || value === null || value === '') continue;
+ search.set(key, String(value));
+ }
+ const qs = search.toString();
+ return qs ? `?${qs}` : '';
+}
+
+interface RequestOptions {
+ params?: object;
+ body?: unknown;
+ isForm?: boolean;
+ retry?: boolean;
+ responseType?: 'json' | 'text' | 'blob';
+}
+
+async function request(
+ method: string,
+ path: string,
+ opts: RequestOptions = {},
+): Promise {
+ const { params, body, isForm, retry = true, responseType = 'json' } = opts;
+
+ const init: RequestInit = {
+ method,
+ credentials: 'include',
+ headers: isForm ? undefined : { 'Content-Type': 'application/json' },
+ body: isForm
+ ? (body as FormData)
+ : body !== undefined
+ ? JSON.stringify(body)
+ : undefined,
+ };
+
+ const res = await fetch(`${API_URL}${path}${buildQuery(params)}`, init);
+
+ if (res.status === 401 && retry && !path.startsWith('/auth/')) {
+ const refreshed = await tryRefresh();
+ if (refreshed) {
+ return request(method, path, { ...opts, retry: false });
+ }
+ if (typeof window !== 'undefined') {
+ window.location.href = '/login';
+ }
+ throw new ApiError(401, 'Sessão expirada');
+ }
+
+ if (res.status === 204) {
+ return undefined as T;
+ }
+
+ if (responseType === 'blob') {
+ if (!res.ok) throw new ApiError(res.status, 'Erro ao baixar arquivo');
+ return (await res.blob()) as T;
+ }
+
+ const text = await res.text();
+ let data: unknown = undefined;
+ if (text) {
+ try {
+ data = JSON.parse(text);
+ } catch {
+ data = text;
+ }
+ }
+
+ if (!res.ok) {
+ const message =
+ data && typeof data === 'object' && 'message' in data
+ ? Array.isArray((data as { message: unknown }).message)
+ ? (data as { message: string[] }).message.join('; ')
+ : String((data as { message: unknown }).message)
+ : `Erro ${res.status}`;
+ throw new ApiError(res.status, message, data);
+ }
+
+ return data as T;
+}
+
+export const api = {
+ get: (path: string, params?: object) =>
+ request('GET', path, { params }),
+ post: (path: string, body?: unknown) => request('POST', path, { body }),
+ patch: (path: string, body?: unknown) => request('PATCH', path, { body }),
+ delete: (path: string) => request('DELETE', path),
+ upload: (path: string, form: FormData, params?: object) =>
+ request('POST', path, { body: form, isForm: true, params }),
+ download: (path: string, params?: object) =>
+ request('GET', path, { params, responseType: 'blob' }),
+};
diff --git a/apps/frontend/src/lib/error-message.ts b/apps/frontend/src/lib/error-message.ts
new file mode 100644
index 0000000..811bffb
--- /dev/null
+++ b/apps/frontend/src/lib/error-message.ts
@@ -0,0 +1,7 @@
+import { ApiError } from '@/lib/api-client';
+
+export function errorMessage(err: unknown): string {
+ if (err instanceof ApiError) return err.message;
+ if (err instanceof Error) return err.message;
+ return 'Erro inesperado.';
+}
diff --git a/apps/frontend/src/lib/permissions.ts b/apps/frontend/src/lib/permissions.ts
new file mode 100644
index 0000000..db2d666
--- /dev/null
+++ b/apps/frontend/src/lib/permissions.ts
@@ -0,0 +1,64 @@
+// Espelha packages/shared/src/permissions.ts — fonte de verdade é o backend
+// (RequirePermissions), isto é só para habilitar/ocultar UI. Nunca confiar
+// só nisso: toda ação real é reforçada pelo guard do NestJS.
+export const PERMISSIONS = [
+ 'dashboard.view',
+ 'trunks.view',
+ 'trunks.create',
+ 'trunks.update',
+ 'trunks.delete',
+ 'extensions.view',
+ 'extensions.create',
+ 'extensions.update',
+ 'extensions.delete',
+ 'dialplans.view',
+ 'dialplans.create',
+ 'dialplans.update',
+ 'dialplans.delete',
+ 'queues.view',
+ 'queues.create',
+ 'queues.update',
+ 'queues.delete',
+ 'agents.view',
+ 'agents.create',
+ 'agents.update',
+ 'agents.delete',
+ 'campaigns.view',
+ 'campaigns.create',
+ 'campaigns.start',
+ 'campaigns.pause',
+ 'campaigns.stop',
+ 'campaigns.update',
+ 'campaigns.delete',
+ 'reports.view',
+ 'reports.export',
+ 'monitoring.view',
+ 'asterisk.view',
+ 'asterisk.configure',
+ 'asterisk.reload',
+ 'users.view',
+ 'users.create',
+ 'users.update',
+ 'roles.manage',
+ 'audit.view',
+ 'settings.manage',
+] as const;
+
+export type Permission = (typeof PERMISSIONS)[number];
+
+export function hasPermission(
+ userPermissions: string[] | undefined,
+ required: Permission | Permission[],
+): boolean {
+ if (!userPermissions) return false;
+ const list = Array.isArray(required) ? required : [required];
+ return list.every((p) => userPermissions.includes(p));
+}
+
+export function hasAnyPermission(
+ userPermissions: string[] | undefined,
+ required: Permission[],
+): boolean {
+ if (!userPermissions) return false;
+ return required.some((p) => userPermissions.includes(p));
+}
diff --git a/apps/frontend/src/lib/utils.ts b/apps/frontend/src/lib/utils.ts
new file mode 100644
index 0000000..b71a749
--- /dev/null
+++ b/apps/frontend/src/lib/utils.ts
@@ -0,0 +1,25 @@
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+export function formatDateTime(value: string | Date | null | undefined): string {
+ if (!value) return '—';
+ const d = typeof value === 'string' ? new Date(value) : value;
+ return d.toLocaleString('pt-BR');
+}
+
+export function formatSeconds(value: number | null | undefined): string {
+ if (value === null || value === undefined || Number.isNaN(value)) return '—';
+ const total = Math.round(value);
+ const m = Math.floor(total / 60);
+ const s = total % 60;
+ return m > 0 ? `${m}m ${s}s` : `${s}s`;
+}
+
+export function formatPercent(value: number | null | undefined): string {
+ if (value === null || value === undefined || Number.isNaN(value)) return '—';
+ return `${(value * 100).toFixed(1)}%`;
+}
diff --git a/apps/frontend/src/middleware.ts b/apps/frontend/src/middleware.ts
new file mode 100644
index 0000000..6fa4090
--- /dev/null
+++ b/apps/frontend/src/middleware.ts
@@ -0,0 +1,33 @@
+import { NextResponse } from 'next/server';
+import type { NextRequest } from 'next/server';
+
+const PUBLIC_PATHS = ['/login', '/esqueci-senha', '/redefinir-senha'];
+
+export function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+ const hasSession = request.cookies.has('access_token');
+ const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p));
+
+ if (!hasSession && !isPublic) {
+ const url = request.nextUrl.clone();
+ url.pathname = '/login';
+ url.searchParams.set('next', pathname);
+ return NextResponse.redirect(url);
+ }
+
+ if (hasSession && pathname === '/login') {
+ const url = request.nextUrl.clone();
+ url.pathname = '/';
+ url.search = '';
+ return NextResponse.redirect(url);
+ }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ // Exclui assets internos do Next e qualquer arquivo estático servido de
+ // /public (extensão no último segmento, ex.: /logo.png) — sem isso a
+ // própria logo da tela de login ficava presa atrás do redirect de sessão.
+ matcher: ['/((?!_next/static|_next/image|favicon|icon|.*\\.[\\w]+$).*)'],
+};
diff --git a/apps/frontend/src/services/agent-console.ts b/apps/frontend/src/services/agent-console.ts
new file mode 100644
index 0000000..79904e2
--- /dev/null
+++ b/apps/frontend/src/services/agent-console.ts
@@ -0,0 +1,13 @@
+import { api } from '@/lib/api-client';
+import type { AgentConsoleState } from '@/types';
+
+export const agentConsoleService = {
+ me: () => api.get('/agent-console/me'),
+ login: (extension: string) =>
+ api.post('/agent-console/login', { extension }),
+ available: () => api.post('/agent-console/available'),
+ pause: (pauseReasonId: string) =>
+ api.post('/agent-console/pause', { pauseReasonId }),
+ unpause: () => api.post('/agent-console/unpause'),
+ logout: () => api.post('/agent-console/logout'),
+};
diff --git a/apps/frontend/src/services/agents.ts b/apps/frontend/src/services/agents.ts
new file mode 100644
index 0000000..24a5398
--- /dev/null
+++ b/apps/frontend/src/services/agents.ts
@@ -0,0 +1,18 @@
+import { api } from '@/lib/api-client';
+import type { Agent } from '@/types';
+
+export interface AgentInput {
+ code?: string;
+ name: string;
+ userId?: string;
+ active?: boolean;
+}
+
+export const agentsService = {
+ list: () => api.get('/agents'),
+ get: (id: string) => api.get(`/agents/${id}`),
+ create: (input: AgentInput) => api.post('/agents', input),
+ update: (id: string, input: Partial) =>
+ api.patch(`/agents/${id}`, input),
+ remove: (id: string) => api.delete(`/agents/${id}`),
+};
diff --git a/apps/frontend/src/services/asterisk.ts b/apps/frontend/src/services/asterisk.ts
new file mode 100644
index 0000000..1e45cf0
--- /dev/null
+++ b/apps/frontend/src/services/asterisk.ts
@@ -0,0 +1,14 @@
+import { api } from '@/lib/api-client';
+import type { AsteriskStatus, DiagnosticResult } from '@/types';
+
+export const asteriskService = {
+ status: () => api.get('/asterisk/status'),
+ modules: () => api.get('/asterisk/modules'),
+ allowedCommands: () => api.get('/asterisk/diagnostic/allowed-commands'),
+ runDiagnostic: (command: string) =>
+ api.post('/asterisk/diagnostic', { command }),
+ reload: (module?: string) =>
+ api.post<{ ok: boolean; module: string }>(
+ `/asterisk/reload${module ? `?module=${encodeURIComponent(module)}` : ''}`,
+ ),
+};
diff --git a/apps/frontend/src/services/audit.ts b/apps/frontend/src/services/audit.ts
new file mode 100644
index 0000000..4293809
--- /dev/null
+++ b/apps/frontend/src/services/audit.ts
@@ -0,0 +1,17 @@
+import { api } from '@/lib/api-client';
+import type { AuditLogEntry, Paginated } from '@/types';
+
+export interface AuditQuery {
+ userId?: string;
+ action?: string;
+ entityType?: string;
+ from?: string;
+ to?: string;
+ page?: number;
+ pageSize?: number;
+}
+
+export const auditService = {
+ query: (query: AuditQuery) =>
+ api.get>('/audit', query),
+};
diff --git a/apps/frontend/src/services/auth.ts b/apps/frontend/src/services/auth.ts
new file mode 100644
index 0000000..04196d5
--- /dev/null
+++ b/apps/frontend/src/services/auth.ts
@@ -0,0 +1,18 @@
+import { api } from '@/lib/api-client';
+import type { CurrentUser } from '@/types';
+
+export const authService = {
+ login: (email: string, password: string) =>
+ api.post<{ mustChangePassword: boolean }>('/auth/login', { email, password }),
+ logout: () => api.post('/auth/logout'),
+ me: () => api.get('/auth/me'),
+ changePassword: (currentPassword: string, newPassword: string) =>
+ api.post<{ ok: boolean }>('/auth/change-password', {
+ currentPassword,
+ newPassword,
+ }),
+ forgotPassword: (email: string) =>
+ api.post<{ ok: boolean }>('/auth/forgot-password', { email }),
+ resetPassword: (token: string, newPassword: string) =>
+ api.post<{ ok: boolean }>('/auth/reset-password', { token, newPassword }),
+};
diff --git a/apps/frontend/src/services/campaigns.ts b/apps/frontend/src/services/campaigns.ts
new file mode 100644
index 0000000..e0a1d83
--- /dev/null
+++ b/apps/frontend/src/services/campaigns.ts
@@ -0,0 +1,42 @@
+import { api } from '@/lib/api-client';
+import type { Campaign } from '@/types';
+
+export interface CampaignInput {
+ name: string;
+ description?: string;
+ queueId: string;
+ trunkId: string;
+ callerId?: string;
+ context?: string;
+ startDate?: string;
+ endDate?: string;
+ daysOfWeek?: number[];
+ startTime?: string;
+ endTime?: string;
+ timezone?: string;
+ maxCps: number;
+ maxConcurrentCalls: number;
+ pacingInitial?: number;
+ pacingMin?: number;
+ pacingMax?: number;
+ targetAbandonRate?: number;
+ maxWaitForAgentSeconds?: number;
+ ringTimeoutSeconds?: number;
+ maxAttempts?: number;
+ retryRules?: Record;
+ amdEnabled?: boolean;
+ wrapUpTimeSeconds?: number;
+}
+
+export const campaignsService = {
+ list: () => api.get('/campaigns'),
+ get: (id: string) => api.get(`/campaigns/${id}`),
+ create: (input: CampaignInput) => api.post('/campaigns', input),
+ update: (id: string, input: Partial) =>
+ api.patch(`/campaigns/${id}`, input),
+ remove: (id: string) => api.delete(`/campaigns/${id}`),
+ start: (id: string) => api.post(`/campaigns/${id}/start`),
+ pause: (id: string) => api.post(`/campaigns/${id}/pause`),
+ stop: (id: string) => api.post(`/campaigns/${id}/stop`),
+ drain: (id: string) => api.post(`/campaigns/${id}/drain`),
+};
diff --git a/apps/frontend/src/services/compliance.ts b/apps/frontend/src/services/compliance.ts
new file mode 100644
index 0000000..ac3e959
--- /dev/null
+++ b/apps/frontend/src/services/compliance.ts
@@ -0,0 +1,16 @@
+import { api } from '@/lib/api-client';
+import type { ComplianceSettings, ComplianceIndicators } from '@/types';
+
+export interface UpdateComplianceInput {
+ shortCallThresholdSeconds?: number;
+ maxAttemptsPerNumberPerDay?: number;
+ maxAttemptsPerNumberPerMonth?: number;
+ highVolumeMonthlyThreshold?: number;
+}
+
+export const complianceService = {
+ getSettings: () => api.get('/compliance/settings'),
+ updateSettings: (input: UpdateComplianceInput) =>
+ api.patch('/compliance/settings', input),
+ indicators: () => api.get('/compliance/indicators'),
+};
diff --git a/apps/frontend/src/services/dashboard.ts b/apps/frontend/src/services/dashboard.ts
new file mode 100644
index 0000000..e578482
--- /dev/null
+++ b/apps/frontend/src/services/dashboard.ts
@@ -0,0 +1,12 @@
+import { api } from '@/lib/api-client';
+import type { DashboardOverview, CampaignDashboard } from '@/types';
+
+export const dashboardService = {
+ overview: () => api.get('/dashboard'),
+ callsByHour: () =>
+ api.get<{ hour: number; total: number; answered: number }[]>(
+ '/dashboard/calls-by-hour',
+ ),
+ campaign: (id: string) =>
+ api.get(`/dashboard/campaigns/${id}`),
+};
diff --git a/apps/frontend/src/services/dialplan.ts b/apps/frontend/src/services/dialplan.ts
new file mode 100644
index 0000000..3cb45c0
--- /dev/null
+++ b/apps/frontend/src/services/dialplan.ts
@@ -0,0 +1,26 @@
+import { api } from '@/lib/api-client';
+import type { DialplanEntry, DialplanVersion } from '@/types';
+
+export interface DialplanEntryInput {
+ context: string;
+ exten: string;
+ priority: number;
+ application: string;
+ argument?: string;
+ enabled?: boolean;
+ order?: number;
+}
+
+export const dialplanService = {
+ list: (context?: string) =>
+ api.get('/dialplans', context ? { context } : undefined),
+ versions: () => api.get('/dialplans/versions'),
+ create: (input: DialplanEntryInput) =>
+ api.post('/dialplans', input),
+ update: (id: string, input: Partial) =>
+ api.patch(`/dialplans/${id}`, input),
+ remove: (id: string) => api.delete(`/dialplans/${id}`),
+ publish: () => api.post('/dialplans/publish'),
+ rollback: (versionId: string) =>
+ api.post(`/dialplans/versions/${versionId}/rollback`),
+};
diff --git a/apps/frontend/src/services/dispositions.ts b/apps/frontend/src/services/dispositions.ts
new file mode 100644
index 0000000..71541ba
--- /dev/null
+++ b/apps/frontend/src/services/dispositions.ts
@@ -0,0 +1,19 @@
+import { api } from '@/lib/api-client';
+import type { Disposition, DispositionAction } from '@/types';
+
+export interface DispositionInput {
+ name: string;
+ code?: string;
+ description?: string;
+ action?: DispositionAction;
+ active?: boolean;
+}
+
+export const dispositionsService = {
+ list: () => api.get('/dispositions'),
+ create: (input: DispositionInput) =>
+ api.post('/dispositions', input),
+ update: (id: string, input: Partial) =>
+ api.patch(`/dispositions/${id}`, input),
+ remove: (id: string) => api.delete(`/dispositions/${id}`),
+};
diff --git a/apps/frontend/src/services/extensions.ts b/apps/frontend/src/services/extensions.ts
new file mode 100644
index 0000000..3b6e881
--- /dev/null
+++ b/apps/frontend/src/services/extensions.ts
@@ -0,0 +1,25 @@
+import { api } from '@/lib/api-client';
+import type { Extension } from '@/types';
+
+export interface ExtensionInput {
+ number?: string;
+ name: string;
+ callerId?: string;
+ context?: string;
+ codecs?: string[];
+ transport?: string;
+ maxContacts?: number;
+ qualifyFrequency?: number;
+ enabled?: boolean;
+}
+
+export const extensionsService = {
+ list: () => api.get('/extensions'),
+ get: (id: string) => api.get(`/extensions/${id}`),
+ create: (input: ExtensionInput) => api.post('/extensions', input),
+ update: (id: string, input: Partial) =>
+ api.patch(`/extensions/${id}`, input),
+ resetPassword: (id: string) =>
+ api.post(`/extensions/${id}/reset-password`),
+ remove: (id: string) => api.delete(`/extensions/${id}`),
+};
diff --git a/apps/frontend/src/services/leads.ts b/apps/frontend/src/services/leads.ts
new file mode 100644
index 0000000..558dd3b
--- /dev/null
+++ b/apps/frontend/src/services/leads.ts
@@ -0,0 +1,22 @@
+import { api } from '@/lib/api-client';
+import type { Lead, LeadImport, LeadStatus, Paginated, ImportResult } from '@/types';
+
+export const leadsService = {
+ query: (
+ campaignId: string,
+ params: { status?: LeadStatus; search?: string; page?: number; pageSize?: number },
+ ) => api.get>(`/campaigns/${campaignId}/leads`, params),
+ imports: (campaignId: string) =>
+ api.get(`/campaigns/${campaignId}/leads/imports`),
+ importCsv: (campaignId: string, file: File, dryRun = false) => {
+ const form = new FormData();
+ form.append('file', file);
+ return api.upload(
+ `/campaigns/${campaignId}/leads/import`,
+ form,
+ { dryRun: dryRun ? 'true' : undefined },
+ );
+ },
+ downloadRejected: (campaignId: string, importId: string) =>
+ api.download(`/campaigns/${campaignId}/leads/imports/${importId}/rejected.csv`),
+};
diff --git a/apps/frontend/src/services/monitoring.ts b/apps/frontend/src/services/monitoring.ts
new file mode 100644
index 0000000..ecc2946
--- /dev/null
+++ b/apps/frontend/src/services/monitoring.ts
@@ -0,0 +1,8 @@
+import { api } from '@/lib/api-client';
+import type { ExtensionMonitor, QueueMonitor, AgentMonitor } from '@/types';
+
+export const monitoringService = {
+ extensions: () => api.get('/monitoring/extensions'),
+ queues: () => api.get('/monitoring/queues'),
+ agents: () => api.get('/monitoring/agents'),
+};
diff --git a/apps/frontend/src/services/pause-reasons.ts b/apps/frontend/src/services/pause-reasons.ts
new file mode 100644
index 0000000..0967500
--- /dev/null
+++ b/apps/frontend/src/services/pause-reasons.ts
@@ -0,0 +1,20 @@
+import { api } from '@/lib/api-client';
+import type { PauseReason } from '@/types';
+
+export interface PauseReasonInput {
+ name: string;
+ code?: string;
+ description?: string;
+ maxDurationSeconds?: number;
+ paid?: boolean;
+ active?: boolean;
+}
+
+export const pauseReasonsService = {
+ list: () => api.get('/pause-reasons'),
+ create: (input: PauseReasonInput) =>
+ api.post('/pause-reasons', input),
+ update: (id: string, input: Partial) =>
+ api.patch(`/pause-reasons/${id}`, input),
+ remove: (id: string) => api.delete(`/pause-reasons/${id}`),
+};
diff --git a/apps/frontend/src/services/queues.ts b/apps/frontend/src/services/queues.ts
new file mode 100644
index 0000000..f95d982
--- /dev/null
+++ b/apps/frontend/src/services/queues.ts
@@ -0,0 +1,32 @@
+import { api } from '@/lib/api-client';
+import type { Queue, QueueStrategy } from '@/types';
+
+export interface QueueInput {
+ name?: string;
+ number?: string;
+ strategy?: QueueStrategy;
+ timeout?: number;
+ retry?: number;
+ wrapUpTime?: number;
+ maxLen?: number;
+ musicOnHold?: string;
+ announce?: string;
+ serviceLevel?: number;
+ autoFill?: boolean;
+ ringInUse?: boolean;
+ weight?: number;
+ enabled?: boolean;
+}
+
+export const queuesService = {
+ list: () => api.get('/queues'),
+ get: (id: string) => api.get(`/queues/${id}`),
+ create: (input: QueueInput) => api.post('/queues', input),
+ update: (id: string, input: Partial) =>
+ api.patch(`/queues/${id}`, input),
+ remove: (id: string) => api.delete(`/queues/${id}`),
+ addMember: (id: string, agentId: string, penalty?: number) =>
+ api.post(`/queues/${id}/members`, { agentId, penalty }),
+ removeMember: (id: string, agentId: string) =>
+ api.delete(`/queues/${id}/members/${agentId}`),
+};
diff --git a/apps/frontend/src/services/reports.ts b/apps/frontend/src/services/reports.ts
new file mode 100644
index 0000000..1529364
--- /dev/null
+++ b/apps/frontend/src/services/reports.ts
@@ -0,0 +1,26 @@
+import { api } from '@/lib/api-client';
+import type { CallsReportResult, CallMetrics, AgentReport } from '@/types';
+
+export interface CallsReportQuery {
+ from?: string;
+ to?: string;
+ campaignId?: string;
+ queueId?: string;
+ agentId?: string;
+ dispositionId?: string;
+ phone?: string;
+ state?: string;
+ page?: number;
+ pageSize?: number;
+}
+
+export const reportsService = {
+ calls: (query: CallsReportQuery) =>
+ api.get('/reports/calls', query),
+ exportCalls: (query: CallsReportQuery) =>
+ api.download('/reports/calls/export', query),
+ metrics: (query: Pick) =>
+ api.get('/reports/metrics', query),
+ agentReport: (agentId: string, from?: string, to?: string) =>
+ api.get(`/reports/agents/${agentId}`, { from, to }),
+};
diff --git a/apps/frontend/src/services/roles.ts b/apps/frontend/src/services/roles.ts
new file mode 100644
index 0000000..ab8cc6c
--- /dev/null
+++ b/apps/frontend/src/services/roles.ts
@@ -0,0 +1,23 @@
+import { api } from '@/lib/api-client';
+import type { Role } from '@/types';
+import type { Permission } from '@/lib/permissions';
+
+export interface CreateRoleInput {
+ name: string;
+ description?: string;
+ permissionKeys: Permission[];
+}
+
+export interface UpdateRoleInput {
+ description?: string;
+ permissionKeys?: Permission[];
+}
+
+export const rolesService = {
+ list: () => api.get('/roles'),
+ permissionCatalog: () => api.get('/roles/permissions'),
+ create: (input: CreateRoleInput) => api.post('/roles', input),
+ update: (id: string, input: UpdateRoleInput) =>
+ api.patch(`/roles/${id}`, input),
+ remove: (id: string) => api.delete(`/roles/${id}`),
+};
diff --git a/apps/frontend/src/services/suppression.ts b/apps/frontend/src/services/suppression.ts
new file mode 100644
index 0000000..b5fb921
--- /dev/null
+++ b/apps/frontend/src/services/suppression.ts
@@ -0,0 +1,21 @@
+import { api } from '@/lib/api-client';
+import type { SuppressionEntry, Paginated } from '@/types';
+
+export interface SuppressionImportResult {
+ added: number;
+ invalid: number;
+ total: number;
+}
+
+export const suppressionService = {
+ query: (params: { search?: string; page?: number; pageSize?: number }) =>
+ api.get