aboutsummaryrefslogtreecommitdiff
path: root/web/src
diff options
context:
space:
mode:
authorzwlucas <lucas.fariamo08@gmail.com>2026-07-25 21:23:29 +0000
committerzwlucas <lucas.fariamo08@gmail.com>2026-07-25 21:23:29 +0000
commitef55d9bb24430610b60c1b77821307cd72c08c55 (patch)
treee6935a810b34370b0a4c821e55f6397aac50c832 /web/src
downloadhelpdesk-ef55d9bb24430610b60c1b77821307cd72c08c55.tar.gz
helpdesk-ef55d9bb24430610b60c1b77821307cd72c08c55.zip
feat: upload of the projectHEADmaster
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'web/src')
-rw-r--r--web/src/App.tsx39
-rw-r--r--web/src/components/auth/ProtectedRoute.tsx20
-rw-r--r--web/src/components/layout/AppLayout.module.css194
-rw-r--r--web/src/components/layout/AppLayout.tsx199
-rw-r--r--web/src/components/tickets/PriorityBadge.tsx25
-rw-r--r--web/src/components/tickets/StatusBadge.tsx26
-rw-r--r--web/src/components/tickets/TicketCard.module.css94
-rw-r--r--web/src/components/tickets/TicketCard.tsx66
-rw-r--r--web/src/components/ui/PageLoader.module.css26
-rw-r--r--web/src/components/ui/PageLoader.tsx28
-rw-r--r--web/src/context/AuthContext.tsx79
-rw-r--r--web/src/context/Toast.module.css75
-rw-r--r--web/src/context/ToastContext.tsx71
-rw-r--r--web/src/hooks/useTicketEvents.ts91
-rw-r--r--web/src/lib/api.ts297
-rw-r--r--web/src/main.tsx16
-rw-r--r--web/src/pages/DashboardPage.module.css160
-rw-r--r--web/src/pages/DashboardPage.tsx224
-rw-r--r--web/src/pages/LoginPage.module.css125
-rw-r--r--web/src/pages/LoginPage.tsx124
-rw-r--r--web/src/pages/NewTicketPage.module.css88
-rw-r--r--web/src/pages/NewTicketPage.tsx203
-rw-r--r--web/src/pages/SettingsPage.module.css111
-rw-r--r--web/src/pages/SettingsPage.tsx63
-rw-r--r--web/src/pages/TicketDetailPage.module.css263
-rw-r--r--web/src/pages/TicketDetailPage.tsx366
-rw-r--r--web/src/pages/UsersPage.module.css121
-rw-r--r--web/src/pages/UsersPage.tsx290
-rw-r--r--web/src/router.tsx70
-rw-r--r--web/src/styles/index.css274
30 files changed, 3828 insertions, 0 deletions
diff --git a/web/src/App.tsx b/web/src/App.tsx
new file mode 100644
index 0000000..e5bf00e
--- /dev/null
+++ b/web/src/App.tsx
@@ -0,0 +1,39 @@
+/**
+ * App.tsx — Raiz da aplicação com todos os providers globais.
+ */
+
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { RouterProvider } from 'react-router-dom';
+import { AuthProvider } from './context/AuthContext';
+import { ToastProvider } from './context/ToastContext';
+import { router } from './router';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 60 * 1000,
+ gcTime: 5 * 60 * 1000,
+ retry: (failureCount, error) => {
+ if (error instanceof Error && 'status' in error) {
+ const status = (error as { status: number }).status;
+ if (status >= 400 && status < 500) return false;
+ }
+ return failureCount < 2;
+ },
+ refetchOnWindowFocus: true,
+ },
+ mutations: { retry: false },
+ },
+});
+
+export default function App() {
+ return (
+ <AuthProvider>
+ <QueryClientProvider client={queryClient}>
+ <ToastProvider>
+ <RouterProvider router={router} />
+ </ToastProvider>
+ </QueryClientProvider>
+ </AuthProvider>
+ );
+}
diff --git a/web/src/components/auth/ProtectedRoute.tsx b/web/src/components/auth/ProtectedRoute.tsx
new file mode 100644
index 0000000..1133d36
--- /dev/null
+++ b/web/src/components/auth/ProtectedRoute.tsx
@@ -0,0 +1,20 @@
+/**
+ * ProtectedRoute.tsx
+ * Redireciona para /login se o usuário não estiver autenticado.
+ */
+
+import { Navigate, useLocation } from 'react-router-dom';
+import { useAuth } from '@/context/AuthContext';
+import type { ReactNode } from 'react';
+
+export function ProtectedRoute({ children }: { children: ReactNode }) {
+ const { isAuthenticated } = useAuth();
+ const location = useLocation();
+
+ if (!isAuthenticated) {
+ // Preserva a rota que o usuário tentou acessar para redirect pós-login
+ return <Navigate to="/login" state={{ from: location }} replace />;
+ }
+
+ return <>{children}</>;
+}
diff --git a/web/src/components/layout/AppLayout.module.css b/web/src/components/layout/AppLayout.module.css
new file mode 100644
index 0000000..a6d32ce
--- /dev/null
+++ b/web/src/components/layout/AppLayout.module.css
@@ -0,0 +1,194 @@
+.shell {
+ display: grid;
+ grid-template-columns: var(--sidebar-width) 1fr;
+ min-height: 100vh;
+}
+
+/* ── Sidebar ───────────────────────────────────────────────────────────────── */
+.sidebar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: var(--sidebar-width);
+ height: 100vh;
+ background: var(--color-surface);
+ border-right: 1px solid var(--color-border);
+ display: flex;
+ flex-direction: column;
+ z-index: 100;
+ overflow-y: auto;
+}
+
+.sidebarHeader {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-5) var(--space-5);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.sidebarTitle {
+ font-size: var(--text-sm);
+ font-weight: 700;
+ color: var(--color-text-primary);
+ letter-spacing: -0.01em;
+ line-height: 1.2;
+}
+
+.sidebarSubtitle {
+ font-size: var(--text-xs);
+ color: var(--color-text-muted);
+ margin-top: 1px;
+}
+
+/* ── Navegação ─────────────────────────────────────────────────────────────── */
+.nav {
+ flex: 1;
+ padding: var(--space-4) var(--space-3);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+.navLabel {
+ font-size: var(--text-xs);
+ font-weight: 600;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ padding: 0 var(--space-3);
+ margin-bottom: var(--space-2);
+}
+
+.navLink {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-md);
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--color-text-secondary);
+ transition: all var(--transition-fast);
+ text-decoration: none;
+}
+
+.navLink:hover {
+ background: var(--color-surface-2);
+ color: var(--color-text-primary);
+}
+
+.navLinkActive {
+ background: var(--color-accent-subtle);
+ color: var(--color-accent);
+}
+
+.navLinkActive:hover {
+ background: var(--color-accent-subtle);
+ color: var(--color-accent-hover);
+}
+
+/* ── Footer da sidebar ─────────────────────────────────────────────────────── */
+.sidebarFooter {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-4) var(--space-4);
+ border-top: 1px solid var(--color-border);
+ margin-top: auto;
+}
+
+.userAvatar {
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-full);
+ background: var(--color-accent-subtle);
+ color: var(--color-accent);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.userInfo {
+ min-width: 0;
+}
+
+.userName {
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--color-text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.userRole {
+ font-size: var(--text-xs);
+ color: var(--color-text-muted);
+}
+
+.logoutBtn {
+ margin-left: auto;
+ flex-shrink: 0;
+ color: var(--color-text-muted);
+ padding: var(--space-1);
+ border-radius: var(--radius-sm);
+ transition: color var(--transition-fast), background var(--transition-fast);
+}
+
+.logoutBtn:hover {
+ color: var(--color-priority-critical);
+ background: rgba(248, 113, 113, 0.1);
+}
+
+
+/* ── Área principal ────────────────────────────────────────────────────────── */
+.main {
+ grid-column: 2;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ── Header ────────────────────────────────────────────────────────────────── */
+.header {
+ position: sticky;
+ top: 0;
+ height: var(--header-height);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 var(--space-8);
+ background: rgba(15, 17, 23, 0.85);
+ backdrop-filter: blur(12px);
+ border-bottom: 1px solid var(--color-border);
+ z-index: 50;
+}
+
+.pageTitle {
+ font-size: var(--text-lg);
+ font-weight: 600;
+ color: var(--color-text-primary);
+ letter-spacing: -0.02em;
+}
+
+.headerActions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+/* ── Conteúdo ──────────────────────────────────────────────────────────────── */
+.content {
+ flex: 1;
+ padding: var(--space-8);
+ max-width: var(--content-max-w);
+ width: 100%;
+ animation: fadeIn var(--transition-normal) ease;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
+}
diff --git a/web/src/components/layout/AppLayout.tsx b/web/src/components/layout/AppLayout.tsx
new file mode 100644
index 0000000..78a1795
--- /dev/null
+++ b/web/src/components/layout/AppLayout.tsx
@@ -0,0 +1,199 @@
+/**
+ * AppLayout.tsx
+ *
+ * Shell principal da aplicação: sidebar + header + área de conteúdo.
+ * Todas as páginas são renderizadas dentro deste layout via <Outlet />.
+ */
+
+import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
+import { useAuth } from '@/context/AuthContext';
+import { useTicketEvents } from '@/hooks/useTicketEvents';
+import styles from './AppLayout.module.css';
+
+
+// ─── Ícones inline (SVG) ──────────────────────────────────────────────────────
+const Icons = {
+ Dashboard: () => (
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+ <rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" />
+ <rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" />
+ </svg>
+ ),
+ Plus: () => (
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+ <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
+ </svg>
+ ),
+ Ticket: () => (
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+ <path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z" />
+ </svg>
+ ),
+ Logo: () => (
+ <svg width="28" height="28" viewBox="0 0 32 32" fill="none" aria-hidden="true">
+ <rect width="32" height="32" rx="8" fill="var(--color-accent)" fillOpacity="0.2" />
+ <path d="M8 11h16M8 16h10M8 21h12" stroke="var(--color-accent)" strokeWidth="2.2" strokeLinecap="round" />
+ </svg>
+ ),
+ User: () => (
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+ <circle cx="12" cy="8" r="4" /><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
+ </svg>
+ ),
+ Users: () => (
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+ <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
+ </svg>
+ ),
+ Settings: () => (
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+ <circle cx="12" cy="12" r="3"></circle>
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
+ </svg>
+ ),
+};
+
+// ─── Mapa de títulos por rota ─────────────────────────────────────────────────
+const ROUTE_TITLES: Record<string, string> = {
+ '/dashboard': 'Dashboard',
+ '/tickets/new': 'Abrir Chamado',
+ '/users': 'Usuários',
+};
+
+function getPageTitle(pathname: string): string {
+ if (ROUTE_TITLES[pathname]) return ROUTE_TITLES[pathname];
+ if (pathname.startsWith('/tickets/')) return 'Detalhe do Chamado';
+ return 'Helpdesk TI';
+}
+
+// ─── Componente ───────────────────────────────────────────────────────────────
+export default function AppLayout() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { user, logout } = useAuth();
+ const pageTitle = getPageTitle(location.pathname);
+
+ // Inicializa o listener de eventos em tempo real
+ useTicketEvents();
+
+ function handleLogout() {
+ logout();
+ navigate('/login', { replace: true });
+ }
+
+
+ return (
+ <div className={styles.shell}>
+
+ {/* ── Sidebar ─────────────────────────────────────────────────────── */}
+ <aside className={styles.sidebar} aria-label="Navegação principal">
+ {/* Logo */}
+ <div className={styles.sidebarHeader}>
+ <Icons.Logo />
+ <div>
+ <p className={styles.sidebarTitle}>Helpdesk TI</p>
+ <p className={styles.sidebarSubtitle}>Central de Chamados</p>
+ </div>
+ </div>
+
+ {/* Navegação */}
+ <nav className={styles.nav}>
+ <p className={styles.navLabel}>Menu</p>
+
+ <NavLink
+ to="/dashboard"
+ className={({ isActive }) =>
+ `${styles.navLink} ${isActive ? styles.navLinkActive : ''}`
+ }
+ >
+ <Icons.Dashboard />
+ <span>Dashboard</span>
+ </NavLink>
+
+ <NavLink
+ to="/tickets/new"
+ className={({ isActive }) =>
+ `${styles.navLink} ${isActive ? styles.navLinkActive : ''}`
+ }
+ >
+ <Icons.Plus />
+ <span>Abrir Chamado</span>
+ </NavLink>
+
+ <NavLink
+ to="/dashboard?status=open"
+ className={({ isActive }) =>
+ `${styles.navLink} ${isActive ? styles.navLinkActive : ''}`
+ }
+ >
+ <Icons.Ticket />
+ <span>Meus Chamados</span>
+ </NavLink>
+
+ {user?.role === 'admin' && (
+ <>
+ <p className={styles.navLabel}>Administração</p>
+ <NavLink
+ to="/users"
+ className={({ isActive }) => `${styles.navLink} ${isActive ? styles.navLinkActive : ''}`}
+ >
+ <Icons.Users />
+ <span>Usuários</span>
+ </NavLink>
+ <NavLink
+ to="/settings"
+ className={({ isActive }) => `${styles.navLink} ${isActive ? styles.navLinkActive : ''}`}
+ >
+ <Icons.Settings />
+ <span>Configurações</span>
+ </NavLink>
+ </>
+ )}
+ </nav>
+
+ {/* Footer da sidebar */}
+ <div className={styles.sidebarFooter}>
+ <div className={styles.userAvatar}>
+ <Icons.User />
+ </div>
+ <div className={styles.userInfo}>
+ <p className={styles.userName}>{user?.name ?? 'Usuário'}</p>
+ <p className={styles.userRole}>{user?.role ?? 'agent'}</p>
+ </div>
+ <button
+ onClick={handleLogout}
+ className={styles.logoutBtn}
+ title="Sair"
+ aria-label="Sair da conta"
+ >
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
+ <polyline points="16 17 21 12 16 7" />
+ <line x1="21" y1="12" x2="9" y2="12" />
+ </svg>
+ </button>
+ </div>
+ </aside>
+
+ {/* ── Área principal ───────────────────────────────────────────────── */}
+ <div className={styles.main}>
+
+ {/* Header */}
+ <header className={styles.header}>
+ <h1 className={styles.pageTitle}>{pageTitle}</h1>
+ <div className={styles.headerActions}>
+ <NavLink to="/tickets/new" className="btn btn--primary">
+ <Icons.Plus />
+ Novo Chamado
+ </NavLink>
+ </div>
+ </header>
+
+ {/* Conteúdo da página */}
+ <main className={styles.content} id="main-content">
+ <Outlet />
+ </main>
+ </div>
+ </div>
+ );
+}
diff --git a/web/src/components/tickets/PriorityBadge.tsx b/web/src/components/tickets/PriorityBadge.tsx
new file mode 100644
index 0000000..acd07a8
--- /dev/null
+++ b/web/src/components/tickets/PriorityBadge.tsx
@@ -0,0 +1,25 @@
+/**
+ * PriorityBadge.tsx
+ * Badge visual para a prioridade do ticket.
+ */
+
+import type { TicketPriority } from '@/lib/api';
+
+const PRIORITY_LABELS: Record<TicketPriority, string> = {
+ low: '↓ Baixa',
+ medium: '→ Média',
+ high: '↑ Alta',
+ critical: '⚡ Crítica',
+};
+
+interface PriorityBadgeProps {
+ priority: TicketPriority;
+}
+
+export function PriorityBadge({ priority }: PriorityBadgeProps) {
+ return (
+ <span className={`badge badge--${priority}`}>
+ {PRIORITY_LABELS[priority]}
+ </span>
+ );
+}
diff --git a/web/src/components/tickets/StatusBadge.tsx b/web/src/components/tickets/StatusBadge.tsx
new file mode 100644
index 0000000..e24876d
--- /dev/null
+++ b/web/src/components/tickets/StatusBadge.tsx
@@ -0,0 +1,26 @@
+/**
+ * StatusBadge.tsx
+ * Badge visual para o status do ticket — usa classes do design system global.
+ */
+
+import type { TicketStatus } from '@/lib/api';
+
+const STATUS_LABELS: Record<TicketStatus, string> = {
+ open: 'Aberto',
+ in_progress: 'Em Andamento',
+ waiting: 'Aguardando',
+ resolved: 'Resolvido',
+ closed: 'Fechado',
+};
+
+interface StatusBadgeProps {
+ status: TicketStatus;
+}
+
+export function StatusBadge({ status }: StatusBadgeProps) {
+ return (
+ <span className={`badge badge--${status}`}>
+ {STATUS_LABELS[status]}
+ </span>
+ );
+}
diff --git a/web/src/components/tickets/TicketCard.module.css b/web/src/components/tickets/TicketCard.module.css
new file mode 100644
index 0000000..494e3d3
--- /dev/null
+++ b/web/src/components/tickets/TicketCard.module.css
@@ -0,0 +1,94 @@
+.card {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-left-width: 3px;
+ border-radius: var(--radius-lg);
+ padding: var(--space-4) var(--space-5);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.card:hover {
+ border-color: var(--color-border-hover);
+ border-left-color: inherit;
+ box-shadow: var(--shadow-md);
+ transform: translateY(-1px);
+}
+
+/* Borda esquerda colorida por prioridade */
+.priority_low { border-left-color: var(--color-priority-low); }
+.priority_medium { border-left-color: var(--color-priority-medium); }
+.priority_high { border-left-color: var(--color-priority-high); }
+.priority_critical { border-left-color: var(--color-priority-critical); }
+
+.top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+}
+
+.badges {
+ display: flex;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+}
+
+.id {
+ font-size: var(--text-xs);
+ color: var(--color-text-muted);
+ font-variant-numeric: tabular-nums;
+ flex-shrink: 0;
+}
+
+.title {
+ font-size: var(--text-base);
+ font-weight: 600;
+ color: var(--color-text-primary);
+ line-height: 1.4;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.description {
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+ line-height: 1.6;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: var(--space-1);
+ padding-top: var(--space-3);
+ border-top: 1px solid var(--color-border);
+}
+
+.meta {
+ font-size: var(--text-xs);
+ color: var(--color-text-muted);
+}
+
+.assigned {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: var(--text-xs);
+ color: var(--color-resolved);
+}
+
+.unassigned {
+ font-size: var(--text-xs);
+ color: var(--color-text-muted);
+ font-style: italic;
+}
diff --git a/web/src/components/tickets/TicketCard.tsx b/web/src/components/tickets/TicketCard.tsx
new file mode 100644
index 0000000..3b3926b
--- /dev/null
+++ b/web/src/components/tickets/TicketCard.tsx
@@ -0,0 +1,66 @@
+/**
+ * TicketCard.tsx
+ * Card de ticket para uso na lista do dashboard.
+ */
+
+import { useNavigate } from 'react-router-dom';
+import type { Ticket } from '@/lib/api';
+import { StatusBadge } from './StatusBadge';
+import { PriorityBadge } from './PriorityBadge';
+import styles from './TicketCard.module.css';
+
+interface TicketCardProps {
+ ticket: Ticket;
+}
+
+function formatDate(iso: string): string {
+ return new Intl.DateTimeFormat('pt-BR', {
+ day: '2-digit',
+ month: 'short',
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(new Date(iso));
+}
+
+export function TicketCard({ ticket }: TicketCardProps) {
+ const navigate = useNavigate();
+
+ return (
+ <article
+ className={`${styles.card} ${styles[`priority_${ticket.priority}`]}`}
+ onClick={() => navigate(`/tickets/${ticket.id}`)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => e.key === 'Enter' && navigate(`/tickets/${ticket.id}`)}
+ aria-label={`Ticket #${ticket.id}: ${ticket.title}`}
+ >
+ <div className={styles.top}>
+ <div className={styles.badges}>
+ <StatusBadge status={ticket.status} />
+ <PriorityBadge priority={ticket.priority} />
+ </div>
+ <span className={styles.id}>#{ticket.id}</span>
+ </div>
+
+ <h3 className={styles.title}>{ticket.title}</h3>
+
+ <p className={styles.description}>{ticket.description}</p>
+
+ <div className={styles.footer}>
+ <span className={styles.meta}>
+ Aberto em {formatDate(ticket.createdAt)}
+ </span>
+ {ticket.assigneeId ? (
+ <span className={styles.assigned}>
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+ <circle cx="12" cy="8" r="4" /><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
+ </svg>
+ Agente #{ticket.assigneeId}
+ </span>
+ ) : (
+ <span className={styles.unassigned}>Sem agente</span>
+ )}
+ </div>
+ </article>
+ );
+}
diff --git a/web/src/components/ui/PageLoader.module.css b/web/src/components/ui/PageLoader.module.css
new file mode 100644
index 0000000..9683313
--- /dev/null
+++ b/web/src/components/ui/PageLoader.module.css
@@ -0,0 +1,26 @@
+.wrapper {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--color-bg);
+ z-index: 9999;
+}
+
+.content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-5);
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ font-size: var(--text-lg);
+ font-weight: 600;
+ color: var(--color-text-primary);
+ letter-spacing: -0.02em;
+}
diff --git a/web/src/components/ui/PageLoader.tsx b/web/src/components/ui/PageLoader.tsx
new file mode 100644
index 0000000..dcb34ab
--- /dev/null
+++ b/web/src/components/ui/PageLoader.tsx
@@ -0,0 +1,28 @@
+/**
+ * PageLoader.tsx
+ * Tela de carregamento exibida enquanto uma rota lazy é importada.
+ */
+
+import styles from './PageLoader.module.css';
+
+export default function PageLoader() {
+ return (
+ <div className={styles.wrapper}>
+ <div className={styles.content}>
+ <div className={styles.logo}>
+ <svg width="32" height="32" viewBox="0 0 32 32" fill="none" aria-hidden="true">
+ <rect width="32" height="32" rx="8" fill="var(--color-accent)" fillOpacity="0.15" />
+ <path
+ d="M8 12h16M8 16h10M8 20h12"
+ stroke="var(--color-accent)"
+ strokeWidth="2"
+ strokeLinecap="round"
+ />
+ </svg>
+ <span>Helpdesk TI</span>
+ </div>
+ <div className="spinner" role="status" aria-label="Carregando..." />
+ </div>
+ </div>
+ );
+}
diff --git a/web/src/context/AuthContext.tsx b/web/src/context/AuthContext.tsx
new file mode 100644
index 0000000..f86a940
--- /dev/null
+++ b/web/src/context/AuthContext.tsx
@@ -0,0 +1,79 @@
+/**
+ * AuthContext.tsx
+ *
+ * Contexto global de autenticação.
+ * Persiste o JWT no localStorage e expõe o estado do usuário logado.
+ */
+
+import {
+ createContext,
+ useContext,
+ useState,
+ useCallback,
+ type ReactNode,
+} from 'react';
+import type { UserResponse } from '@/lib/api';
+
+// ─── Tipos ────────────────────────────────────────────────────────────────────
+
+interface AuthState {
+ user: UserResponse | null;
+ token: string | null;
+ isAuthenticated: boolean;
+ login: (token: string, user: UserResponse) => void;
+ logout: () => void;
+}
+
+// ─── Chave no localStorage ────────────────────────────────────────────────────
+
+const TOKEN_KEY = 'helpdesk:token';
+const USER_KEY = 'helpdesk:user';
+
+// ─── Context ──────────────────────────────────────────────────────────────────
+
+const AuthContext = createContext<AuthState | null>(null);
+
+// ─── Provider ─────────────────────────────────────────────────────────────────
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ // Inicializa com dados persistidos (restaura sessão ao recarregar)
+ const [token, setToken] = useState<string | null>(
+ () => localStorage.getItem(TOKEN_KEY),
+ );
+ const [user, setUser] = useState<UserResponse | null>(() => {
+ const stored = localStorage.getItem(USER_KEY);
+ return stored ? (JSON.parse(stored) as UserResponse) : null;
+ });
+
+ const login = useCallback((newToken: string, newUser: UserResponse) => {
+ localStorage.setItem(TOKEN_KEY, newToken);
+ localStorage.setItem(USER_KEY, JSON.stringify(newUser));
+ setToken(newToken);
+ setUser(newUser);
+ }, []);
+
+ const logout = useCallback(() => {
+ localStorage.removeItem(TOKEN_KEY);
+ localStorage.removeItem(USER_KEY);
+ setToken(null);
+ setUser(null);
+ }, []);
+
+ return (
+ <AuthContext.Provider
+ value={{ user, token, isAuthenticated: Boolean(token), login, logout }}
+ >
+ {children}
+ </AuthContext.Provider>
+ );
+}
+
+// ─── Hook ─────────────────────────────────────────────────────────────────────
+
+export function useAuth(): AuthState {
+ const ctx = useContext(AuthContext);
+ if (!ctx) {
+ throw new Error('useAuth deve ser usado dentro de <AuthProvider>');
+ }
+ return ctx;
+}
diff --git a/web/src/context/Toast.module.css b/web/src/context/Toast.module.css
new file mode 100644
index 0000000..bb56336
--- /dev/null
+++ b/web/src/context/Toast.module.css
@@ -0,0 +1,75 @@
+.toastContainer {
+ position: fixed;
+ bottom: var(--space-6);
+ right: var(--space-6);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ z-index: 9999;
+ pointer-events: none; /* Deixa cliques passarem pelo container vazio */
+}
+
+.toast {
+ pointer-events: auto; /* Reativa cliques dentro do toast */
+ min-width: 300px;
+ background: var(--color-surface);
+ color: var(--color-text-primary);
+ border: 1px solid var(--color-border);
+ padding: var(--space-3) var(--space-4);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ font-size: var(--text-sm);
+ font-weight: 500;
+ backdrop-filter: blur(8px);
+
+ animation: slideIn var(--transition-normal) ease forwards;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateX(100%) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0) scale(1);
+ }
+}
+
+.info {
+ border-left: 4px solid var(--color-accent);
+}
+
+.success {
+ border-left: 4px solid var(--color-priority-low);
+}
+
+.error {
+ border-left: 4px solid var(--color-priority-critical);
+}
+
+.closeBtn {
+ background: none;
+ border: none;
+ color: var(--color-text-muted);
+ font-size: var(--text-lg);
+ cursor: pointer;
+ padding: 0;
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ border-radius: var(--radius-sm);
+ transition: background var(--transition-fast), color var(--transition-fast);
+}
+
+.closeBtn:hover {
+ background: var(--color-bg);
+ color: var(--color-text-primary);
+}
diff --git a/web/src/context/ToastContext.tsx b/web/src/context/ToastContext.tsx
new file mode 100644
index 0000000..87b3894
--- /dev/null
+++ b/web/src/context/ToastContext.tsx
@@ -0,0 +1,71 @@
+/**
+ * ToastContext.tsx
+ * Sistema minimalista de Toast Notifications.
+ */
+
+import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
+import { createPortal } from 'react-dom';
+import styles from './Toast.module.css';
+
+type ToastType = 'info' | 'success' | 'error';
+
+interface ToastData {
+ id: string;
+ message: string;
+ type: ToastType;
+}
+
+interface ToastContextValue {
+ addToast: (message: string, type?: ToastType) => void;
+}
+
+const ToastContext = createContext<ToastContextValue | null>(null);
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+ const [toasts, setToasts] = useState<ToastData[]>([]);
+
+ const addToast = useCallback((message: string, type: ToastType = 'info') => {
+ const id = Math.random().toString(36).substring(2, 9);
+ setToasts((prev) => [...prev, { id, message, type }]);
+
+ // Remove automaticamente após 4 segundos
+ setTimeout(() => {
+ setToasts((prev) => prev.filter((t) => t.id !== id));
+ }, 4000);
+ }, []);
+
+ const removeToast = (id: string) => {
+ setToasts((prev) => prev.filter((t) => t.id !== id));
+ };
+
+ return (
+ <ToastContext.Provider value={{ addToast }}>
+ {children}
+
+ {/* Portal renderiza os toasts no final do <body> */}
+ {createPortal(
+ <div className={styles.toastContainer}>
+ {toasts.map((toast) => (
+ <div key={toast.id} className={`${styles.toast} ${styles[toast.type]}`}>
+ <span>{toast.message}</span>
+ <button
+ className={styles.closeBtn}
+ onClick={() => removeToast(toast.id)}
+ aria-label="Fechar"
+ >
+ &times;
+ </button>
+ </div>
+ ))}
+ </div>,
+ document.body
+ )}
+ </ToastContext.Provider>
+ );
+}
+
+export function useToast() {
+ const ctx = useContext(ToastContext);
+ if (!ctx) throw new Error('useToast deve ser usado dentro do ToastProvider');
+ return ctx;
+}
diff --git a/web/src/hooks/useTicketEvents.ts b/web/src/hooks/useTicketEvents.ts
new file mode 100644
index 0000000..7994ace
--- /dev/null
+++ b/web/src/hooks/useTicketEvents.ts
@@ -0,0 +1,91 @@
+/**
+ * useTicketEvents.ts
+ *
+ * Hook para escutar os Server-Sent Events (SSE) disparados pelo backend.
+ * Conecta via EventSource e despacha atualizações e invalidações de cache.
+ */
+
+import { useEffect } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { useAuth } from '@/context/AuthContext';
+import { useToast } from '@/context/ToastContext';
+
+export function useTicketEvents() {
+ const { token, isAuthenticated, user } = useAuth();
+ const queryClient = useQueryClient();
+ const { addToast } = useToast();
+
+ useEffect(() => {
+ // Pede permissão para notificações nativas, se suportado
+ if ('Notification' in window && Notification.permission === 'default') {
+ Notification.requestPermission();
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!isAuthenticated || !token) return;
+
+ // Conecta ao endpoint SSE passando o token na query string
+ const url = `${import.meta.env['VITE_API_URL'] ?? '/api/v1'}/events?token=${token}`;
+ const eventSource = new EventSource(url);
+
+ const notify = (title: string, body?: string) => {
+ // Toast notification
+ addToast(title, 'info');
+ // Native notification
+ if ('Notification' in window && Notification.permission === 'granted') {
+ new Notification(title, { body, icon: '/favicon.ico' });
+ }
+ };
+
+ // Quando um novo chamado é criado
+ eventSource.addEventListener('ticket.created', (e) => {
+ const data = JSON.parse(e.data);
+ if (user?.role === 'admin' || user?.role === 'agent') {
+ if (data.creatorId !== user.id) {
+ notify(`Novo chamado: #${data.id}`, data.title);
+ }
+ }
+
+ // Invalida a lista para forçar o refetch e atualizar o grid
+ queryClient.invalidateQueries({ queryKey: ['tickets'] });
+ queryClient.invalidateQueries({ queryKey: ['stats'] });
+ });
+
+ // Quando o status ou responsável do chamado é atualizado
+ eventSource.addEventListener('ticket.updated', (e) => {
+ const data = JSON.parse(e.data);
+ if (user && (user.id === data.creatorId || user.id === data.assigneeId) && user.id !== data.authorId) {
+ notify(`Chamado #${data.id} atualizado`, `O status ou responsável foi alterado.`);
+ }
+
+ // Atualiza as listas, os stats e o detalhe do ticket
+ queryClient.invalidateQueries({ queryKey: ['tickets'] });
+ queryClient.invalidateQueries({ queryKey: ['stats'] });
+ queryClient.invalidateQueries({ queryKey: ['ticket', data.id] });
+ });
+
+ // Quando um comentário é adicionado
+ eventSource.addEventListener('comment.added', (e) => {
+ const data = JSON.parse(e.data);
+ if (user && (user.id === data.creatorId || user.id === data.assigneeId) && user.id !== data.authorId) {
+ if (!data.isInternal || (data.isInternal && user.role !== 'requester')) {
+ notify(`Nova mensagem no chamado #${data.ticketId}`, 'Você tem uma nova interação no chamado.');
+ }
+ }
+
+ // Atualiza os comentários do ticket afetado
+ queryClient.invalidateQueries({ queryKey: ['tickets', data.ticketId, 'comments'] });
+ });
+
+ // Tratamento de erro na conexão
+ eventSource.onerror = (err) => {
+ console.error('Erro na conexão SSE:', err);
+ // O navegador tentará reconectar automaticamente
+ };
+
+ return () => {
+ eventSource.close();
+ };
+ }, [token, isAuthenticated, queryClient, addToast]);
+}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
new file mode 100644
index 0000000..1d1e63d
--- /dev/null
+++ b/web/src/lib/api.ts
@@ -0,0 +1,297 @@
+/**
+ * api.ts
+ *
+ * Cliente HTTP tipado para a API Helpdesk.
+ * Centraliza toda comunicação com o backend — nenhum componente
+ * deve chamar fetch() diretamente.
+ */
+
+// ─── Configuração ─────────────────────────────────────────────────────────────
+
+const BASE_URL = import.meta.env['VITE_API_URL'] ?? '/api/v1';
+const TOKEN_KEY = 'helpdesk:token';
+
+function getToken(): string | null {
+ return localStorage.getItem(TOKEN_KEY);
+}
+
+
+// ─── Tipos (espelham o schema do backend) ─────────────────────────────────────
+
+export type TicketStatus = 'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed';
+export type TicketPriority = 'low' | 'medium' | 'high' | 'critical';
+
+export interface Category {
+ id: number;
+ name: string;
+ slug: string;
+ description: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface Ticket {
+ id: number;
+ title: string;
+ description: string;
+ status: TicketStatus;
+ priority: TicketPriority;
+ creatorId: number;
+ assigneeId: number | null;
+ categoryId: number | null;
+ dueAt: string | null;
+ resolvedAt: string | null;
+ createdAt: string;
+ updatedAt: string;
+ creatorName?: string;
+ assigneeName?: string | null;
+ categoryName?: string | null;
+}
+
+export interface Comment {
+ id: number;
+ content: string;
+ isInternal: boolean;
+ ticketId: number;
+ authorId: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface PaginatedResponse<T> {
+ data: T[];
+ meta: {
+ page: number;
+ limit: number;
+ total: number;
+ totalPages: number;
+ };
+}
+
+export interface SystemSettings {
+ allowRequesterChat: boolean;
+}
+
+// ─── Filtros para listagem ────────────────────────────────────────────────────
+
+export interface ListTicketsParams {
+ status?: TicketStatus;
+ priority?: TicketPriority;
+ assigneeId?: number;
+ creatorId?: number;
+ categoryId?: number;
+ page?: number;
+ limit?: number;
+}
+
+// ─── Erro padronizado da API ──────────────────────────────────────────────────
+
+export class ApiError extends Error {
+ constructor(
+ public readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ this.name = 'ApiError';
+ }
+}
+
+// ─── Função base de fetch ─────────────────────────────────────────────────────
+
+async function request<T>(
+ path: string,
+ options?: RequestInit,
+): Promise<T> {
+ const token = getToken();
+ const headers: Record<string, string> = { ...options?.headers as Record<string, string> };
+
+ if (options?.body && !headers['Content-Type']) {
+ headers['Content-Type'] = 'application/json';
+ }
+ if (token && !headers['Authorization']) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+
+ const response = await fetch(`${BASE_URL}${path}`, {
+ cache: 'no-store',
+ ...options,
+ headers,
+ });
+
+ if (!response.ok) {
+ const body = await response.json().catch(() => ({ message: response.statusText }));
+ throw new ApiError(
+ response.status,
+ (body as { message?: string }).message ?? 'Erro desconhecido',
+ );
+ }
+
+ if (response.status === 204) return null as T;
+
+ return response.json() as Promise<T>;
+}
+
+// ─── Tipos adicionais para Auth ───────────────────────────────────────────────
+
+export interface UserResponse {
+ id: number;
+ name: string;
+ email: string;
+ role: 'requester' | 'agent' | 'admin';
+ isActive: boolean;
+ createdAt: string;
+}
+
+export interface AuthResponse {
+ token: string;
+ user: UserResponse;
+}
+
+export interface StatsResponse {
+ totalOpen: number;
+ totalInProgress: number;
+ totalWaiting: number;
+ totalResolved: number;
+ totalClosed: number;
+ totalPending: number;
+}
+
+// ─── Endpoints: Tickets ───────────────────────────────────────────────────────
+
+export const api = {
+ settings: {
+ get(): Promise<SystemSettings> {
+ return request('/settings');
+ },
+ update(data: { allowRequesterChat: boolean }): Promise<SystemSettings> {
+ return request('/settings', { method: 'PATCH', body: JSON.stringify(data) });
+ }
+ },
+
+ stats: {
+ get(): Promise<StatsResponse> {
+ return request('/stats');
+ },
+ },
+
+ auth: {
+ login(data: { email: string; password: string }): Promise<AuthResponse> {
+ return request('/auth/login', { method: 'POST', body: JSON.stringify(data) });
+ },
+ register(data: { name: string; email: string; password: string; role?: string }): Promise<AuthResponse> {
+ return request('/auth/register', { method: 'POST', body: JSON.stringify(data) });
+ },
+ me(): Promise<UserResponse> {
+ return request('/auth/me');
+ },
+ },
+
+ categories: {
+ list(): Promise<Category[]> {
+ return request('/categories');
+ },
+ get(id: number): Promise<Category> {
+ return request(`/categories/${id}`);
+ },
+ create(data: { name: string; slug?: string; description?: string }): Promise<Category> {
+ return request('/categories', { method: 'POST', body: JSON.stringify(data) });
+ },
+ update(id: number, data: { name?: string; slug?: string; description?: string }): Promise<Category> {
+ return request(`/categories/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
+ },
+ delete(id: number): Promise<void> {
+ return request(`/categories/${id}`, { method: 'DELETE' });
+ }
+ },
+
+ tickets: {
+ list(params: ListTicketsParams = {}): Promise<PaginatedResponse<Ticket>> {
+ const query = new URLSearchParams();
+ Object.entries(params).forEach(([key, value]) => {
+ if (value !== undefined) query.set(key, String(value));
+ });
+ const qs = query.toString();
+ return request(`/tickets${qs ? `?${qs}` : ''}`);
+ },
+
+ get(id: number): Promise<Ticket> {
+ return request(`/tickets/${id}`);
+ },
+
+ create(data: {
+ title: string;
+ description: string;
+ priority?: TicketPriority;
+ categoryId?: number;
+ creatorId: number;
+ }): Promise<Ticket> {
+ return request('/tickets', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+ },
+
+ updateStatus(ticketId: number, status: TicketStatus, reason?: string): Promise<Ticket> {
+ return request(`/tickets/${ticketId}/status`, {
+ method: 'PATCH',
+ body: JSON.stringify({ status, reason }),
+ });
+ },
+
+ assign(ticketId: number, assigneeId: number): Promise<Ticket> {
+ return request(`/tickets/${ticketId}/assign`, {
+ method: 'PATCH',
+ body: JSON.stringify({ assigneeId }),
+ });
+ },
+
+ updatePriority(ticketId: number, priority: TicketPriority): Promise<Ticket> {
+ return request(`/tickets/${ticketId}/priority`, {
+ method: 'PATCH',
+ body: JSON.stringify({ priority }),
+ });
+ },
+
+ delete(id: number): Promise<void> {
+ return request(`/tickets/${id}`, { method: 'DELETE' });
+ },
+ },
+
+ comments: {
+ list(ticketId: number): Promise<Comment[]> {
+ return request(`/tickets/${ticketId}/comments`);
+ },
+
+ add(ticketId: number, data: {
+ content: string;
+ isInternal: boolean;
+ authorId: number;
+ }): Promise<Comment> {
+ return request(`/tickets/${ticketId}/comments`, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+ },
+ },
+
+ users: {
+ list(): Promise<UserResponse[]> {
+ return request('/users');
+ },
+ create(data: { name: string; email: string; password: string; role: 'admin' | 'agent' | 'requester' }): Promise<UserResponse> {
+ return request('/users', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+ },
+ update(id: number, data: { name?: string; email?: string; password?: string; role?: 'admin' | 'agent' | 'requester'; isActive?: boolean }): Promise<UserResponse> {
+ return request(`/users/${id}`, {
+ method: 'PATCH',
+ body: JSON.stringify(data),
+ });
+ },
+ delete(id: number): Promise<void> {
+ return request(`/users/${id}`, { method: 'DELETE' });
+ }
+ },
+};
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..66e73d2
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,16 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import '@/styles/index.css';
+import App from './App';
+
+const rootElement = document.getElementById('root');
+
+if (!rootElement) {
+ throw new Error('Elemento #root não encontrado no DOM. Verifique o index.html.');
+}
+
+createRoot(rootElement).render(
+ <StrictMode>
+ <App />
+ </StrictMode>,
+);
diff --git a/web/src/pages/DashboardPage.module.css b/web/src/pages/DashboardPage.module.css
new file mode 100644
index 0000000..59e1a73
--- /dev/null
+++ b/web/src/pages/DashboardPage.module.css
@@ -0,0 +1,160 @@
+.page {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+}
+
+.pageHeader {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+}
+
+.heading {
+ font-size: var(--text-2xl);
+ font-weight: 700;
+ color: var(--color-text-primary);
+ letter-spacing: -0.03em;
+}
+
+.total {
+ font-size: var(--text-sm);
+ color: var(--color-text-muted);
+ margin-top: var(--space-1);
+}
+
+/* ── KPI Cards ─────────────────────────────────────────────────────────────── */
+.kpiGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: var(--space-4);
+ margin-bottom: var(--space-2);
+}
+
+.kpiCard {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-4);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+.kpiLabel {
+ font-size: var(--text-xs);
+ font-weight: 600;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.kpiValue {
+ font-size: var(--text-3xl);
+ font-weight: 700;
+ color: var(--color-text-primary);
+ line-height: 1;
+}
+
+/* ── Filtros ───────────────────────────────────────────────────────────────── */
+.filters {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+}
+
+/* ── Grid de tickets ───────────────────────────────────────────────────────── */
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: var(--space-4);
+}
+
+/* ── Skeleton ──────────────────────────────────────────────────────────────── */
+.skeleton {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-5);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ overflow: hidden;
+}
+
+.skeletonLine {
+ border-radius: var(--radius-sm);
+ background: linear-gradient(
+ 90deg,
+ var(--color-surface-2) 25%,
+ var(--color-border) 50%,
+ var(--color-surface-2) 75%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.5s infinite;
+}
+
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* ── Estado vazio ──────────────────────────────────────────────────────────── */
+.emptyState {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-3);
+ padding: var(--space-16) var(--space-8);
+ text-align: center;
+}
+
+.emptyTitle {
+ font-size: var(--text-lg);
+ font-weight: 600;
+ color: var(--color-text-secondary);
+}
+
+.emptyMessage {
+ font-size: var(--text-sm);
+ color: var(--color-text-muted);
+}
+
+/* ── Estado de erro ────────────────────────────────────────────────────────── */
+.errorState {
+ padding: var(--space-6);
+ background: rgba(248, 113, 113, 0.08);
+ border: 1px solid rgba(248, 113, 113, 0.25);
+ border-radius: var(--radius-lg);
+ text-align: center;
+}
+
+.errorTitle {
+ font-weight: 600;
+ color: var(--color-priority-critical);
+ margin-bottom: var(--space-1);
+}
+
+.errorMessage {
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+}
+
+/* ── Paginação ─────────────────────────────────────────────────────────────── */
+.pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-4);
+ padding-top: var(--space-4);
+ border-top: 1px solid var(--color-border);
+}
+
+.pageInfo {
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+ min-width: 120px;
+ text-align: center;
+}
diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx
new file mode 100644
index 0000000..fa7c5b5
--- /dev/null
+++ b/web/src/pages/DashboardPage.tsx
@@ -0,0 +1,224 @@
+/**
+ * DashboardPage.tsx
+ *
+ * Painel de fila de tickets — visão principal do agente de TI.
+ * Exibe todos os chamados com filtros por status e prioridade.
+ */
+
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { api, type TicketStatus, type TicketPriority } from '@/lib/api';
+import { TicketCard } from '@/components/tickets/TicketCard';
+import styles from './DashboardPage.module.css';
+
+// ─── Opções dos filtros ───────────────────────────────────────────────────────
+
+const STATUS_OPTIONS: { value: TicketStatus | ''; label: string }[] = [
+ { value: '', label: 'Todos os status' },
+ { value: 'open', label: '🔵 Abertos' },
+ { value: 'in_progress', label: '🟣 Em Andamento' },
+ { value: 'waiting', label: '🟡 Aguardando' },
+ { value: 'resolved', label: '🟢 Resolvidos' },
+ { value: 'closed', label: '⚫ Fechados' },
+];
+
+const PRIORITY_OPTIONS: { value: TicketPriority | ''; label: string }[] = [
+ { value: '', label: 'Todas as prioridades' },
+ { value: 'critical', label: '⚡ Crítica' },
+ { value: 'high', label: '↑ Alta' },
+ { value: 'medium', label: '→ Média' },
+ { value: 'low', label: '↓ Baixa' },
+];
+
+// ─── Skeleton de carregamento ─────────────────────────────────────────────────
+
+function TicketSkeleton() {
+ return (
+ <div className={styles.skeleton} aria-hidden="true">
+ <div className={styles.skeletonLine} style={{ width: '30%', height: 20 }} />
+ <div className={styles.skeletonLine} style={{ width: '80%', height: 18 }} />
+ <div className={styles.skeletonLine} style={{ width: '60%', height: 14 }} />
+ </div>
+ );
+}
+
+// ─── Componente principal ─────────────────────────────────────────────────────
+
+export default function DashboardPage() {
+ // ── Filtros ────────────────────────────────────────────────────────────────
+ const [status, setStatus] = useState<TicketStatus | ''>('');
+ const [priority, setPriority] = useState<TicketPriority | ''>('');
+ const [page, setPage] = useState(1);
+
+ const LIMIT = 12;
+
+ // ── Query ──────────────────────────────────────────────────────────────────
+ // A query re-executa automaticamente sempre que os filtros ou a página mudam
+ const { data, isLoading, isError, error } = useQuery({
+ queryKey: ['tickets', { status, priority, page }],
+ queryFn: () =>
+ api.tickets.list({
+ status: status || undefined,
+ priority: priority || undefined,
+ page,
+ limit: LIMIT,
+ }),
+ });
+
+ const { data: stats } = useQuery({
+ queryKey: ['stats'],
+ queryFn: () => api.stats.get(),
+ });
+
+ const tickets = data?.data ?? [];
+ const meta = data?.meta;
+ const totalPages = meta?.totalPages ?? 1;
+
+ // Reseta para página 1 ao mudar filtros
+ function handleStatusChange(value: TicketStatus | '') {
+ setStatus(value);
+ setPage(1);
+ }
+
+ function handlePriorityChange(value: TicketPriority | '') {
+ setPriority(value);
+ setPage(1);
+ }
+
+ return (
+ <div className={styles.page}>
+
+ {/* ── Cabeçalho + stats ─────────────────────────────────────────────── */}
+ <div className={styles.pageHeader}>
+ <div>
+ <h2 className={styles.heading}>Fila de Chamados</h2>
+ {meta && (
+ <p className={styles.total}>
+ {meta.total} chamado{meta.total !== 1 ? 's' : ''} encontrado{meta.total !== 1 ? 's' : ''}
+ </p>
+ )}
+ </div>
+ </div>
+
+ {/* ── KPI Cards ─────────────────────────────────────────────────────── */}
+ {stats && (
+ <div className={styles.kpiGrid}>
+ <div className={styles.kpiCard}>
+ <span className={styles.kpiLabel}>Pendentes</span>
+ <strong className={styles.kpiValue}>{stats.totalPending}</strong>
+ </div>
+ <div className={styles.kpiCard}>
+ <span className={styles.kpiLabel}>Aguardando</span>
+ <strong className={styles.kpiValue}>{stats.totalWaiting}</strong>
+ </div>
+ <div className={styles.kpiCard}>
+ <span className={styles.kpiLabel}>Resolvidos</span>
+ <strong className={styles.kpiValue}>{stats.totalResolved}</strong>
+ </div>
+ </div>
+ )}
+
+ {/* ── Filtros ───────────────────────────────────────────────────────── */}
+ <div className={styles.filters} role="search" aria-label="Filtros de chamados">
+ <select
+ className="input"
+ value={status}
+ onChange={(e) => handleStatusChange(e.target.value as TicketStatus | '')}
+ aria-label="Filtrar por status"
+ style={{ maxWidth: 220 }}
+ >
+ {STATUS_OPTIONS.map((o) => (
+ <option key={o.value} value={o.value}>{o.label}</option>
+ ))}
+ </select>
+
+ <select
+ className="input"
+ value={priority}
+ onChange={(e) => handlePriorityChange(e.target.value as TicketPriority | '')}
+ aria-label="Filtrar por prioridade"
+ style={{ maxWidth: 220 }}
+ >
+ {PRIORITY_OPTIONS.map((o) => (
+ <option key={o.value} value={o.value}>{o.label}</option>
+ ))}
+ </select>
+
+ {(status || priority) && (
+ <button
+ className="btn btn--ghost"
+ onClick={() => { handleStatusChange(''); handlePriorityChange(''); }}
+ >
+ Limpar filtros
+ </button>
+ )}
+ </div>
+
+ {/* ── Estados: loading / erro / vazio / lista ───────────────────────── */}
+ {isLoading && (
+ <div className={styles.grid} aria-busy="true" aria-label="Carregando chamados">
+ {Array.from({ length: 6 }).map((_, i) => (
+ <TicketSkeleton key={i} />
+ ))}
+ </div>
+ )}
+
+ {isError && (
+ <div className={styles.errorState} role="alert">
+ <p className={styles.errorTitle}>Falha ao carregar chamados</p>
+ <p className={styles.errorMessage}>
+ {error instanceof Error ? error.message : 'Erro desconhecido'}
+ </p>
+ </div>
+ )}
+
+ {!isLoading && !isError && tickets.length === 0 && (
+ <div className={styles.emptyState}>
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+ <path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z" />
+ <path d="M13 5v2M13 17v2M13 11v2" />
+ </svg>
+ <p className={styles.emptyTitle}>Nenhum chamado encontrado</p>
+ <p className={styles.emptyMessage}>
+ {status || priority
+ ? 'Tente ajustar os filtros acima.'
+ : 'Nenhum chamado aberto no momento.'}
+ </p>
+ </div>
+ )}
+
+ {!isLoading && tickets.length > 0 && (
+ <div className={styles.grid}>
+ {tickets.map((ticket) => (
+ <TicketCard key={ticket.id} ticket={ticket} />
+ ))}
+ </div>
+ )}
+
+ {/* ── Paginação ─────────────────────────────────────────────────────── */}
+ {totalPages > 1 && (
+ <div className={styles.pagination} aria-label="Paginação">
+ <button
+ className="btn btn--ghost"
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
+ disabled={page <= 1}
+ >
+ ← Anterior
+ </button>
+
+ <span className={styles.pageInfo}>
+ Página {page} de {totalPages}
+ </span>
+
+ <button
+ className="btn btn--ghost"
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
+ disabled={page >= totalPages}
+ >
+ Próxima →
+ </button>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/web/src/pages/LoginPage.module.css b/web/src/pages/LoginPage.module.css
new file mode 100644
index 0000000..0205736
--- /dev/null
+++ b/web/src/pages/LoginPage.module.css
@@ -0,0 +1,125 @@
+.page {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-6);
+ background:
+ radial-gradient(ellipse at 60% 20%, rgba(108, 126, 248, 0.08) 0%, transparent 60%),
+ radial-gradient(ellipse at 20% 80%, rgba(108, 126, 248, 0.05) 0%, transparent 50%),
+ var(--color-bg);
+}
+
+.card {
+ width: 100%;
+ max-width: 400px;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-xl);
+ padding: var(--space-8);
+ box-shadow: var(--shadow-lg);
+ animation: slideUp var(--transition-normal) ease;
+}
+
+@keyframes slideUp {
+ from { opacity: 0; transform: translateY(16px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ margin-bottom: var(--space-6);
+}
+
+.appName {
+ font-size: var(--text-lg);
+ font-weight: 700;
+ color: var(--color-text-primary);
+ letter-spacing: -0.02em;
+ line-height: 1.2;
+}
+
+.appSub {
+ font-size: var(--text-xs);
+ color: var(--color-text-muted);
+}
+
+.heading {
+ font-size: var(--text-xl);
+ font-weight: 600;
+ color: var(--color-text-primary);
+ letter-spacing: -0.02em;
+ margin-bottom: var(--space-6);
+}
+
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.label {
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--color-text-primary);
+}
+
+.passwordWrapper {
+ position: relative;
+}
+
+.passwordWrapper .input {
+ padding-right: var(--space-10);
+}
+
+.showPass {
+ position: absolute;
+ right: var(--space-3);
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: var(--text-base);
+ line-height: 1;
+ color: var(--color-text-muted);
+ transition: color var(--transition-fast);
+}
+
+.showPass:hover {
+ color: var(--color-text-secondary);
+}
+
+.error {
+ padding: var(--space-3) var(--space-4);
+ background: rgba(248, 113, 113, 0.1);
+ border: 1px solid rgba(248, 113, 113, 0.3);
+ border-radius: var(--radius-md);
+ color: var(--color-priority-critical);
+ font-size: var(--text-sm);
+ animation: fadeIn var(--transition-fast) ease;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.submitBtn {
+ width: 100%;
+ padding: var(--space-3);
+ font-size: var(--text-base);
+ margin-top: var(--space-2);
+}
+
+.registerLink {
+ font-size: var(--text-sm);
+ color: var(--color-text-muted);
+ text-align: center;
+ margin-top: var(--space-5);
+}
diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..73f355b
--- /dev/null
+++ b/web/src/pages/LoginPage.tsx
@@ -0,0 +1,124 @@
+/**
+ * LoginPage.tsx
+ * Página de autenticação — ponto de entrada da aplicação.
+ */
+
+import { useState, type FormEvent } from 'react';
+import { useNavigate, Link } from 'react-router-dom';
+import { useMutation } from '@tanstack/react-query';
+import { api } from '@/lib/api';
+import { useAuth } from '@/context/AuthContext';
+import { useToast } from '@/context/ToastContext';
+import styles from './LoginPage.module.css';
+
+export default function LoginPage() {
+ const navigate = useNavigate();
+ const { login } = useAuth();
+ const { addToast } = useToast();
+
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [showPass, setShowPass] = useState(false);
+
+ const { mutate, isPending, isError, error } = useMutation({
+ mutationFn: () => api.auth.login({ email, password }),
+ onSuccess: ({ token, user }) => {
+ login(token, user);
+ addToast('Login realizado com sucesso!', 'success');
+ navigate('/dashboard', { replace: true });
+ },
+ onError: (err: any) => {
+ addToast(err.message || 'Erro ao realizar login', 'error');
+ }
+ });
+
+ function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ mutate();
+ }
+
+ const errorMsg = isError && error instanceof Error ? error.message : null;
+
+ return (
+ <div className={styles.page}>
+ <div className={styles.card}>
+
+ {/* Logo */}
+ <div className={styles.logo}>
+ <svg width="40" height="40" viewBox="0 0 32 32" fill="none" aria-hidden="true">
+ <rect width="32" height="32" rx="8" fill="var(--color-accent)" fillOpacity="0.2" />
+ <path d="M8 11h16M8 16h10M8 21h12" stroke="var(--color-accent)" strokeWidth="2.2" strokeLinecap="round" />
+ </svg>
+ <div>
+ <h1 className={styles.appName}>Helpdesk TI</h1>
+ <p className={styles.appSub}>Central de Chamados</p>
+ </div>
+ </div>
+
+ <h2 className={styles.heading}>Entrar na sua conta</h2>
+
+ <form className={styles.form} onSubmit={handleSubmit} noValidate>
+ <div className={styles.field}>
+ <label htmlFor="login-email" className={styles.label}>E-mail</label>
+ <input
+ id="login-email"
+ type="email"
+ className="input"
+ placeholder="seu@email.com"
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ required
+ autoComplete="email"
+ disabled={isPending}
+ />
+ </div>
+
+ <div className={styles.field}>
+ <label htmlFor="login-password" className={styles.label}>Senha</label>
+ <div className={styles.passwordWrapper}>
+ <input
+ id="login-password"
+ type={showPass ? 'text' : 'password'}
+ className="input"
+ placeholder="••••••••"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ required
+ autoComplete="current-password"
+ disabled={isPending}
+ />
+ <button
+ type="button"
+ className={styles.showPass}
+ onClick={() => setShowPass((v) => !v)}
+ aria-label={showPass ? 'Ocultar senha' : 'Mostrar senha'}
+ >
+ {showPass ? '🙈' : '👁️'}
+ </button>
+ </div>
+ </div>
+
+ {errorMsg && (
+ <div className={styles.error} role="alert">{errorMsg}</div>
+ )}
+
+ <button
+ type="submit"
+ className={`btn btn--primary ${styles.submitBtn}`}
+ disabled={isPending || !email || !password}
+ aria-busy={isPending}
+ >
+ {isPending ? (
+ <><span className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} /> Entrando...</>
+ ) : 'Entrar'}
+ </button>
+ </form>
+
+ <p className={styles.registerLink}>
+ Não tem conta?{' '}
+ <Link to="/register">Solicite acesso ao administrador</Link>
+ </p>
+ </div>
+ </div>
+ );
+}
diff --git a/web/src/pages/NewTicketPage.module.css b/web/src/pages/NewTicketPage.module.css
new file mode 100644
index 0000000..fb73226
--- /dev/null
+++ b/web/src/pages/NewTicketPage.module.css
@@ -0,0 +1,88 @@
+.page {
+ max-width: 680px;
+}
+
+.header {
+ margin-bottom: var(--space-8);
+}
+
+.heading {
+ font-size: var(--text-2xl);
+ font-weight: 700;
+ color: var(--color-text-primary);
+ letter-spacing: -0.03em;
+ margin-bottom: var(--space-2);
+}
+
+.subheading {
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+ line-height: 1.6;
+}
+
+/* ── Formulário ────────────────────────────────────────────────────────────── */
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+}
+
+.fieldGroup {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.label {
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--color-text-primary);
+}
+
+.required {
+ color: var(--color-priority-critical);
+ margin-left: 2px;
+}
+
+.hint {
+ font-size: var(--text-xs);
+ color: var(--color-text-muted);
+}
+
+.textarea {
+ resize: vertical;
+ min-height: 140px;
+ line-height: 1.7;
+}
+
+/* ── Linha de 2 colunas ────────────────────────────────────────────────────── */
+.row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-5);
+}
+
+/* ── Banner de erro ────────────────────────────────────────────────────────── */
+.errorBanner {
+ padding: var(--space-3) var(--space-4);
+ background: rgba(248, 113, 113, 0.1);
+ border: 1px solid rgba(248, 113, 113, 0.3);
+ border-radius: var(--radius-md);
+ color: var(--color-priority-critical);
+ font-size: var(--text-sm);
+ animation: fadeIn var(--transition-fast) ease;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(-4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* ── Ações ─────────────────────────────────────────────────────────────────── */
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--space-3);
+ padding-top: var(--space-4);
+ border-top: 1px solid var(--color-border);
+}
diff --git a/web/src/pages/NewTicketPage.tsx b/web/src/pages/NewTicketPage.tsx
new file mode 100644
index 0000000..d716f1e
--- /dev/null
+++ b/web/src/pages/NewTicketPage.tsx
@@ -0,0 +1,203 @@
+/**
+ * NewTicketPage.tsx
+ *
+ * Formulário para abertura de um novo chamado de TI.
+ * Visão do Solicitante — Fase 4, Passo 2.
+ */
+
+import { useState, type FormEvent } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { api, type TicketPriority } from '@/lib/api';
+import { useAuth } from '@/context/AuthContext';
+import { useToast } from '@/context/ToastContext';
+import styles from './NewTicketPage.module.css';
+
+// ─── Dados estáticos dos selects ──────────────────────────────────────────────
+
+const PRIORITIES: { value: TicketPriority; label: string; description: string }[] = [
+ { value: 'low', label: '🟢 Baixa', description: 'Sem impacto imediato' },
+ { value: 'medium', label: '🔵 Média', description: 'Impacto moderado' },
+ { value: 'high', label: '🟡 Alta', description: 'Impacto significativo' },
+ { value: 'critical', label: '🔴 Crítica', description: 'Sistema/serviço fora do ar' },
+];
+
+// ─── Componente ───────────────────────────────────────────────────────────────
+
+export default function NewTicketPage() {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { user } = useAuth();
+ const { addToast } = useToast();
+
+ // Estado do formulário
+ const [title, setTitle] = useState('');
+ const [description, setDesc] = useState('');
+ const [priority, setPriority] = useState<TicketPriority>('medium');
+ const [categoryId, setCategoryId] = useState<number | undefined>(undefined);
+
+ // Query: categorias
+ const { data: categories = [], isLoading: isLoadingCategories } = useQuery({
+ queryKey: ['categories'],
+ queryFn: () => api.categories.list(),
+ });
+
+ // Mutation: criação do ticket via API
+ const { mutate: createTicket, isPending } = useMutation({
+ mutationFn: () =>
+ api.tickets.create({
+ title: title.trim(),
+ description: description.trim(),
+ priority,
+ categoryId,
+ creatorId: user!.id,
+ }),
+ onSuccess: (data) => {
+ void queryClient.invalidateQueries({ queryKey: ['tickets'] });
+ addToast('Chamado criado com sucesso!', 'success');
+ navigate(`/tickets/${data.id}`);
+ },
+ onError: (err: any) => {
+ addToast(`Erro ao criar chamado: ${err.message}`, 'error');
+ },
+ });
+
+ function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ createTicket();
+ }
+
+ return (
+ <div className={styles.page}>
+
+ {/* Cabeçalho da página */}
+ <div className={styles.header}>
+ <h2 className={styles.heading}>Novo Chamado</h2>
+ <p className={styles.subheading}>
+ Descreva o problema com o máximo de detalhes possível para agilizar o atendimento.
+ </p>
+ </div>
+
+ {/* Formulário */}
+ <form className={styles.form} onSubmit={handleSubmit} noValidate>
+
+ {/* Título */}
+ <div className={styles.fieldGroup}>
+ <label htmlFor="ticket-title" className={styles.label}>
+ Título <span className={styles.required} aria-hidden="true">*</span>
+ </label>
+ <input
+ id="ticket-title"
+ type="text"
+ className="input"
+ placeholder="Ex: Computador não liga após atualização"
+ value={title}
+ onChange={(e) => setTitle(e.target.value)}
+ required
+ minLength={5}
+ maxLength={150}
+ disabled={isPending}
+ aria-describedby="title-hint"
+ />
+ <p id="title-hint" className={styles.hint}>
+ Seja objetivo — este é o resumo que aparece na fila dos agentes.
+ </p>
+ </div>
+
+ {/* Descrição */}
+ <div className={styles.fieldGroup}>
+ <label htmlFor="ticket-description" className={styles.label}>
+ Descrição detalhada <span className={styles.required} aria-hidden="true">*</span>
+ </label>
+ <textarea
+ id="ticket-description"
+ className={`input ${styles.textarea}`}
+ placeholder="Descreva o que aconteceu, quando começou, quais passos já foram tentados e o impacto no seu trabalho..."
+ value={description}
+ onChange={(e) => setDesc(e.target.value)}
+ required
+ minLength={10}
+ rows={6}
+ disabled={isPending}
+ />
+ </div>
+
+ {/* Linha: Prioridade + Categoria */}
+ <div className={styles.row}>
+
+ {/* Prioridade */}
+ <div className={styles.fieldGroup}>
+ <label htmlFor="ticket-priority" className={styles.label}>
+ Prioridade
+ </label>
+ <select
+ id="ticket-priority"
+ className="input"
+ value={priority}
+ onChange={(e) => setPriority(e.target.value as TicketPriority)}
+ disabled={isPending}
+ >
+ {PRIORITIES.map((p) => (
+ <option key={p.value} value={p.value}>
+ {p.label} — {p.description}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ {/* Categoria */}
+ <div className={styles.fieldGroup}>
+ <label htmlFor="ticket-category" className={styles.label}>
+ Categoria
+ </label>
+ <select
+ id="ticket-category"
+ className="input"
+ value={categoryId ?? ''}
+ onChange={(e) =>
+ setCategoryId(e.target.value ? Number(e.target.value) : undefined)
+ }
+ disabled={isPending || isLoadingCategories}
+ >
+ <option value="">Selecione uma categoria...</option>
+ {categories.map((c) => (
+ <option key={c.id} value={c.id}>
+ {c.name}
+ </option>
+ ))}
+ </select>
+ </div>
+ </div>
+
+ {/* Ações */}
+ <div className={styles.actions}>
+ <button
+ type="button"
+ className="btn btn--ghost"
+ onClick={() => navigate('/dashboard')}
+ disabled={isPending}
+ >
+ Cancelar
+ </button>
+
+ <button
+ type="submit"
+ className="btn btn--primary"
+ disabled={isPending || title.trim().length < 5 || description.trim().length < 10}
+ aria-busy={isPending}
+ >
+ {isPending ? (
+ <>
+ <span className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
+ Enviando...
+ </>
+ ) : (
+ 'Abrir Chamado'
+ )}
+ </button>
+ </div>
+
+ </form>
+ </div>
+ );
+}
diff --git a/web/src/pages/SettingsPage.module.css b/web/src/pages/SettingsPage.module.css
new file mode 100644
index 0000000..715f97b
--- /dev/null
+++ b/web/src/pages/SettingsPage.module.css
@@ -0,0 +1,111 @@
+.page {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+}
+
+.header {
+ margin-bottom: var(--space-2);
+}
+
+.heading {
+ font-size: var(--text-xl);
+ font-weight: 700;
+ color: var(--color-text-primary);
+ margin-bottom: var(--space-1);
+}
+
+.subheading {
+ color: var(--color-text-muted);
+ font-size: var(--text-sm);
+}
+
+.card {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-6);
+ box-shadow: var(--shadow-sm);
+}
+
+.settingRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+ padding-bottom: var(--space-4);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.settingRow:last-child {
+ padding-bottom: 0;
+ border-bottom: none;
+}
+
+.settingInfo {
+ flex: 1;
+}
+
+.settingTitle {
+ font-size: var(--text-md);
+ font-weight: 600;
+ color: var(--color-text-primary);
+ margin-bottom: var(--space-1);
+}
+
+.settingDesc {
+ font-size: var(--text-sm);
+ color: var(--color-text-muted);
+}
+
+/* Toggle Switch */
+.switch {
+ position: relative;
+ display: inline-block;
+ width: 48px;
+ height: 24px;
+}
+
+.switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--color-surface-2);
+ transition: .4s;
+ border-radius: 24px;
+}
+
+.slider:before {
+ position: absolute;
+ content: "";
+ height: 18px;
+ width: 18px;
+ left: 3px;
+ bottom: 3px;
+ background-color: white;
+ transition: .4s;
+ border-radius: 50%;
+ box-shadow: var(--shadow-sm);
+}
+
+input:checked + .slider {
+ background-color: var(--color-accent);
+}
+
+input:checked + .slider:before {
+ transform: translateX(24px);
+}
+
+input:disabled + .slider {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx
new file mode 100644
index 0000000..2e56213
--- /dev/null
+++ b/web/src/pages/SettingsPage.tsx
@@ -0,0 +1,63 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { api } from '@/lib/api';
+import { useToast } from '@/context/ToastContext';
+import styles from './SettingsPage.module.css';
+
+export default function SettingsPage() {
+ const queryClient = useQueryClient();
+ const { addToast } = useToast();
+
+ const { data: settings, isLoading } = useQuery({
+ queryKey: ['settings'],
+ queryFn: () => api.settings.get(),
+ });
+
+ const mutation = useMutation({
+ mutationFn: (newVal: boolean) => api.settings.update({ allowRequesterChat: newVal }),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ['settings'] });
+ addToast('Configuração atualizada com sucesso', 'success');
+ },
+ onError: (err: any) => {
+ addToast(`Erro ao atualizar: ${err.message}`, 'error');
+ }
+ });
+
+ if (isLoading) {
+ return (
+ <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
+ <div className="spinner" /> Carregando configurações...
+ </div>
+ );
+ }
+
+ return (
+ <div className={styles.page}>
+ <div className={styles.header}>
+ <h2 className={styles.heading}>Configurações do Sistema</h2>
+ <p className={styles.subheading}>Gerencie as preferências globais da plataforma de chamados.</p>
+ </div>
+
+ <div className={styles.card}>
+ <div className={styles.settingRow}>
+ <div className={styles.settingInfo}>
+ <h3 className={styles.settingTitle}>Chat de Solicitantes</h3>
+ <p className={styles.settingDesc}>
+ Se ativado, usuários padrão (requesters) poderão enviar e receber mensagens dentro dos chamados. Se desativado, apenas agentes e administradores poderão interagir no chat.
+ </p>
+ </div>
+
+ <label className={styles.switch}>
+ <input
+ type="checkbox"
+ checked={settings?.allowRequesterChat ?? true}
+ disabled={mutation.isPending}
+ onChange={(e) => mutation.mutate(e.target.checked)}
+ />
+ <span className={styles.slider}></span>
+ </label>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/web/src/pages/TicketDetailPage.module.css b/web/src/pages/TicketDetailPage.module.css
new file mode 100644
index 0000000..36f4781
--- /dev/null
+++ b/web/src/pages/TicketDetailPage.module.css
@@ -0,0 +1,263 @@
+.page {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+ max-width: 1100px;
+}
+
+/* ── Header do ticket ──────────────────────────────────────────────────────── */
+.ticketHeader {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.headerTop {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.ticketId {
+ font-size: var(--text-sm);
+ color: var(--color-text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.ticketTitle {
+ font-size: var(--text-2xl);
+ font-weight: 700;
+ color: var(--color-text-primary);
+ letter-spacing: -0.02em;
+ line-height: 1.3;
+}
+
+.headerMeta {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+}
+
+.metaDate {
+ font-size: var(--text-xs);
+ color: var(--color-text-muted);
+}
+
+/* ── Layout 2 colunas ──────────────────────────────────────────────────────── */
+.layout {
+ display: grid;
+ grid-template-columns: 1fr 280px;
+ gap: var(--space-6);
+ align-items: start;
+}
+
+.mainCol {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-5);
+}
+
+.sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ position: sticky;
+ top: calc(var(--header-height) + var(--space-4));
+}
+
+/* ── Seções ────────────────────────────────────────────────────────────────── */
+.sectionTitle {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin-bottom: var(--space-3);
+}
+
+.description {
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+ line-height: 1.8;
+ white-space: pre-wrap;
+}
+
+/* ── Timeline de comentários ───────────────────────────────────────────────── */
+.timeline {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ margin-top: var(--space-3);
+}
+
+.comment {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-4);
+ transition: border-color var(--transition-fast);
+}
+
+.comment:hover {
+ border-color: var(--color-border-hover);
+}
+
+/* Notas internas têm visual diferenciado */
+.commentInternal {
+ background: rgba(245, 158, 11, 0.05);
+ border-color: rgba(245, 158, 11, 0.2);
+}
+
+.commentHeader {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin-bottom: var(--space-2);
+}
+
+.commentAvatar {
+ font-size: var(--text-base);
+ width: 28px;
+ text-align: center;
+ flex-shrink: 0;
+}
+
+.commentAuthor {
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--color-text-primary);
+}
+
+.internalTag {
+ display: inline-block;
+ margin-left: var(--space-2);
+ font-size: var(--text-xs);
+ color: var(--color-waiting);
+ background: rgba(245, 158, 11, 0.12);
+ padding: 1px 6px;
+ border-radius: var(--radius-full);
+}
+
+.commentDate {
+ font-size: var(--text-xs);
+ color: var(--color-text-muted);
+ margin-left: auto;
+ flex-shrink: 0;
+}
+
+.commentContent {
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+ line-height: 1.7;
+ white-space: pre-wrap;
+ padding-left: calc(28px + var(--space-2));
+}
+
+.commentsLoading {
+ display: flex;
+ justify-content: center;
+ padding: var(--space-6);
+}
+
+.noComments {
+ font-size: var(--text-sm);
+ color: var(--color-text-muted);
+ font-style: italic;
+ text-align: center;
+ padding: var(--space-6);
+}
+
+/* ── Reply ─────────────────────────────────────────────────────────────────── */
+.replySection {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.replyTextarea {
+ resize: vertical;
+ min-height: 100px;
+}
+
+.replyActions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.internalToggle {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ user-select: none;
+}
+
+/* ── Sidebar de informações ────────────────────────────────────────────────── */
+.statusActions {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.statusBtn {
+ width: 100%;
+ justify-content: center;
+}
+
+.infoList {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.infoRow {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: var(--space-2);
+}
+
+.infoRow dt {
+ font-size: var(--text-xs);
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ flex-shrink: 0;
+}
+
+.infoRow dd {
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+ text-align: right;
+}
+
+/* ── Loading / Error states ────────────────────────────────────────────────── */
+.loadingWrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-4);
+ padding: var(--space-16);
+ color: var(--color-text-muted);
+ font-size: var(--text-sm);
+}
+
+.errorWrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-4);
+ padding: var(--space-16);
+ text-align: center;
+}
+
+.errorTitle {
+ font-size: var(--text-xl);
+ font-weight: 600;
+ color: var(--color-text-secondary);
+}
diff --git a/web/src/pages/TicketDetailPage.tsx b/web/src/pages/TicketDetailPage.tsx
new file mode 100644
index 0000000..dbf3066
--- /dev/null
+++ b/web/src/pages/TicketDetailPage.tsx
@@ -0,0 +1,366 @@
+/**
+ * TicketDetailPage.tsx
+ *
+ * Página de detalhe de um chamado — visão do Agente.
+ * Exibe informações completas, histórico de comentários e ações.
+ */
+
+import { useState } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { api, type TicketStatus } from '@/lib/api';
+import { useAuth } from '@/context/AuthContext';
+import { useToast } from '@/context/ToastContext';
+import { StatusBadge } from '@/components/tickets/StatusBadge';
+import { PriorityBadge } from '@/components/tickets/PriorityBadge';
+import styles from './TicketDetailPage.module.css';
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function formatDateTime(iso: string) {
+ return new Intl.DateTimeFormat('pt-BR', {
+ dateStyle: 'short',
+ timeStyle: 'short',
+ }).format(new Date(iso));
+}
+
+const NEXT_STATUSES: Record<TicketStatus, TicketStatus[]> = {
+ open: ['in_progress', 'closed'],
+ in_progress: ['waiting', 'resolved', 'closed'],
+ waiting: ['in_progress', 'resolved', 'closed'],
+ resolved: ['closed', 'open'],
+ closed: [],
+};
+
+const STATUS_LABELS: Record<TicketStatus, string> = {
+ open: 'Aberto',
+ in_progress: 'Em Andamento',
+ waiting: 'Aguardando',
+ resolved: 'Resolvido',
+ closed: 'Fechado',
+};
+
+// ─── Componente ───────────────────────────────────────────────────────────────
+
+export default function TicketDetailPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { user } = useAuth();
+ const { addToast } = useToast();
+ const ticketId = Number(id);
+
+ // ── Queries ────────────────────────────────────────────────────────────────
+ const ticketQuery = useQuery({
+ queryKey: ['tickets', ticketId],
+ queryFn: () => api.tickets.get(ticketId),
+ enabled: Boolean(ticketId),
+ });
+
+ const commentsQuery = useQuery({
+ queryKey: ['tickets', ticketId, 'comments'],
+ queryFn: () => api.comments.list(ticketId),
+ enabled: Boolean(ticketId),
+ });
+
+ const settingsQuery = useQuery({
+ queryKey: ['settings'],
+ queryFn: () => api.settings.get(),
+ });
+
+ // ── State do formulário de comentário ─────────────────────────────────────
+ const [commentText, setCommentText] = useState('');
+ const [isInternal, setIsInternal] = useState(false);
+
+ // ── Mutations ──────────────────────────────────────────────────────────────
+ const addCommentMutation = useMutation({
+ mutationFn: () =>
+ api.comments.add(ticketId, {
+ content: commentText.trim(),
+ isInternal,
+ authorId: user?.id ?? 0,
+ }),
+ onSuccess: () => {
+ setCommentText('');
+ setIsInternal(false);
+ void queryClient.invalidateQueries({ queryKey: ['tickets', ticketId, 'comments'] });
+ },
+ });
+
+ const updateStatusMutation = useMutation({
+ mutationFn: (status: TicketStatus) =>
+ api.tickets.updateStatus(ticketId, status),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ['tickets', ticketId] });
+ void queryClient.invalidateQueries({ queryKey: ['tickets'] });
+ addToast('Status atualizado', 'success');
+ },
+ });
+
+ const assignTicketMutation = useMutation({
+ mutationFn: () => api.tickets.assign(ticketId, user!.id),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ['tickets', ticketId] });
+ void queryClient.invalidateQueries({ queryKey: ['tickets'] });
+ addToast('Você assumiu o chamado', 'success');
+ },
+ });
+
+ const deleteTicketMutation = useMutation({
+ mutationFn: () => api.tickets.delete(ticketId),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ['tickets'] });
+ addToast('Chamado excluído com sucesso.', 'success');
+ navigate('/dashboard');
+ },
+ onError: (err: any) => {
+ addToast(`Erro ao excluir chamado: ${err.message}`, 'error');
+ }
+ });
+
+ // ── Loading / Error states ─────────────────────────────────────────────────
+ if (ticketQuery.isLoading) {
+ return (
+ <div className={styles.loadingWrapper}>
+ <div className="spinner" style={{ width: 28, height: 28, borderWidth: 3 }} />
+ <p>Carregando chamado...</p>
+ </div>
+ );
+ }
+
+ if (ticketQuery.isError || !ticketQuery.data) {
+ return (
+ <div className={styles.errorWrapper} role="alert">
+ <p className={styles.errorTitle}>Chamado não encontrado</p>
+ <button className="btn btn--ghost" onClick={() => navigate('/dashboard')}>
+ ← Voltar ao Dashboard
+ </button>
+ </div>
+ );
+ }
+
+ const ticket = ticketQuery.data;
+ const comments = commentsQuery.data ?? [];
+ const nextStatuses = NEXT_STATUSES[ticket.status];
+
+ return (
+ <div className={styles.page}>
+
+ {/* ── Header do ticket ─────────────────────────────────────────────── */}
+ <div className={styles.ticketHeader}>
+ <div className={styles.headerTop}>
+ <button className="btn btn--ghost" onClick={() => navigate('/dashboard')}>
+ ← Voltar
+ </button>
+ <span className={styles.ticketId}>#{ticket.id}</span>
+ </div>
+
+ <h2 className={styles.ticketTitle}>{ticket.title}</h2>
+
+ <div className={styles.headerMeta}>
+ <StatusBadge status={ticket.status} />
+ <PriorityBadge priority={ticket.priority} />
+ <span className={styles.metaDate}>
+ Aberto em {formatDateTime(ticket.createdAt)}
+ </span>
+ </div>
+ </div>
+
+ {/* ── Layout de 2 colunas ──────────────────────────────────────────── */}
+ <div className={styles.layout}>
+
+ {/* ── Coluna principal ─────────────────────────────────────────── */}
+ <div className={styles.mainCol}>
+
+ {/* Descrição */}
+ <section className="card" aria-labelledby="desc-heading">
+ <h3 id="desc-heading" className={styles.sectionTitle}>Descrição</h3>
+ <p className={styles.description}>{ticket.description}</p>
+ </section>
+
+ {/* Timeline de comentários */}
+ <section aria-labelledby="comments-heading">
+ <h3 id="comments-heading" className={styles.sectionTitle}>
+ Histórico ({comments.length})
+ </h3>
+
+ {commentsQuery.isLoading && (
+ <div className={styles.commentsLoading}>
+ <div className="spinner" />
+ </div>
+ )}
+
+ {comments.length === 0 && !commentsQuery.isLoading && (
+ <p className={styles.noComments}>Nenhuma interação ainda.</p>
+ )}
+
+ <div className={styles.timeline}>
+ {comments.map((comment) => (
+ <article
+ key={comment.id}
+ className={`${styles.comment} ${comment.isInternal ? styles.commentInternal : ''}`}
+ >
+ <div className={styles.commentHeader}>
+ <div className={styles.commentAvatar}>
+ {comment.isInternal ? '🔒' : '💬'}
+ </div>
+ <div>
+ <span className={styles.commentAuthor}>
+ {comment.authorName ?? `Usuário #${comment.authorId}`}
+ </span>
+ {comment.isInternal && (
+ <span className={styles.internalTag}>Nota Interna</span>
+ )}
+ </div>
+ <time className={styles.commentDate} dateTime={comment.createdAt}>
+ {formatDateTime(comment.createdAt)}
+ </time>
+ </div>
+ <p className={styles.commentContent}>{comment.content}</p>
+ </article>
+ ))}
+ </div>
+ </section>
+
+ {/* Formulário de resposta */}
+ {ticket.status !== 'closed' && ticket.status !== 'resolved' && !(user?.role === 'requester' && settingsQuery.data && !settingsQuery.data.allowRequesterChat) && (
+ <section className={`card ${styles.replySection}`} aria-labelledby="reply-heading">
+ <h3 id="reply-heading" className={styles.sectionTitle}>Adicionar Resposta</h3>
+
+ <>
+ <textarea
+ className={`input ${styles.replyTextarea}`}
+ placeholder={
+ isInternal
+ ? 'Nota interna (visível apenas para agentes)...'
+ : 'Resposta pública para o solicitante...'
+ }
+ value={commentText}
+ onChange={(e) => setCommentText(e.target.value)}
+ rows={4}
+ disabled={addCommentMutation.isPending}
+ aria-label="Texto do comentário"
+ />
+
+ <div className={styles.replyActions}>
+ {user?.role !== 'requester' && (
+ <label className={styles.internalToggle}>
+ <input
+ type="checkbox"
+ checked={isInternal}
+ onChange={(e) => setIsInternal(e.target.checked)}
+ disabled={addCommentMutation.isPending}
+ />
+ 🔒 Nota interna
+ </label>
+ )}
+
+ <button
+ className="btn btn--primary"
+ onClick={() => addCommentMutation.mutate()}
+ disabled={
+ addCommentMutation.isPending ||
+ commentText.trim().length === 0
+ }
+ aria-busy={addCommentMutation.isPending}
+ style={{ marginLeft: user?.role === 'requester' ? 'auto' : undefined }}
+ >
+ {addCommentMutation.isPending ? 'Enviando...' : 'Enviar'}
+ </button>
+ </div>
+ </>
+ </section>
+ )}
+ </div>
+
+ {/* ── Sidebar de ações ─────────────────────────────────────────── */}
+ <aside className={styles.sidebar}>
+
+ {/* Mudar status (somente se estiver atribuído a alguém e o usuário for admin ou agent) */}
+ {nextStatuses.length > 0 && (user?.role === 'admin' || user?.role === 'agent') && ticket.assigneeId && (
+ <div className="card">
+ <h3 className={styles.sectionTitle}>Mudar Status</h3>
+ <div className={styles.statusActions}>
+ {nextStatuses.map((s) => (
+ <button
+ key={s}
+ className={`btn btn--ghost ${styles.statusBtn}`}
+ onClick={() => updateStatusMutation.mutate(s)}
+ disabled={updateStatusMutation.isPending}
+ >
+ {STATUS_LABELS[s]}
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Ações do Agente */}
+ {(user?.role === 'admin' || user?.role === 'agent') && !ticket.assigneeId && (
+ <div className="card" style={{ marginTop: '16px' }}>
+ <button
+ className="btn btn--primary"
+ style={{ width: '100%', justifyContent: 'center' }}
+ disabled={assignTicketMutation.isPending}
+ onClick={() => assignTicketMutation.mutate()}
+ >
+ {assignTicketMutation.isPending ? 'Assumindo...' : 'Assumir Chamado'}
+ </button>
+ </div>
+ )}
+
+ {/* Informações */}
+ <div className="card">
+ <h3 className={styles.sectionTitle}>Informações</h3>
+ <dl className={styles.infoList}>
+ <div className={styles.infoRow}>
+ <dt>Criador</dt>
+ <dd>{ticket.creatorName ?? `Usuário #${ticket.creatorId}`}</dd>
+ </div>
+ <div className={styles.infoRow}>
+ <dt>Agente</dt>
+ <dd>{ticket.assigneeId ? (ticket.assigneeName ?? `Agente #${ticket.assigneeId}`) : '—'}</dd>
+ </div>
+ <div className={styles.infoRow}>
+ <dt>Categoria</dt>
+ <dd>{ticket.categoryName ?? (ticket.categoryId ? `Categoria #${ticket.categoryId}` : '—')}</dd>
+ </div>
+ <div className={styles.infoRow}>
+ <dt>Criado em</dt>
+ <dd>{formatDateTime(ticket.createdAt)}</dd>
+ </div>
+ <div className={styles.infoRow}>
+ <dt>Atualizado</dt>
+ <dd>{formatDateTime(ticket.updatedAt)}</dd>
+ </div>
+ {ticket.resolvedAt && (
+ <div className={styles.infoRow}>
+ <dt>Resolvido em</dt>
+ <dd>{formatDateTime(ticket.resolvedAt)}</dd>
+ </div>
+ )}
+ </dl>
+ </div>
+
+ {(user?.role === 'admin' || user?.role === 'agent') && (
+ <div className="card" style={{ marginTop: '16px', borderColor: '#fca5a5' }}>
+ <h3 className={styles.sectionTitle} style={{ color: '#991b1b' }}>Zona de Perigo</h3>
+ <button
+ className="btn btn--danger"
+ style={{ width: '100%', justifyContent: 'center' }}
+ disabled={deleteTicketMutation.isPending}
+ onClick={() => {
+ if (confirm('Tem certeza que deseja excluir permanentemente este chamado?')) {
+ deleteTicketMutation.mutate();
+ }
+ }}
+ >
+ {deleteTicketMutation.isPending ? 'Excluindo...' : 'Excluir Chamado'}
+ </button>
+ </div>
+ )}
+ </aside>
+ </div>
+ </div>
+ );
+}
diff --git a/web/src/pages/UsersPage.module.css b/web/src/pages/UsersPage.module.css
new file mode 100644
index 0000000..296b5bf
--- /dev/null
+++ b/web/src/pages/UsersPage.module.css
@@ -0,0 +1,121 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+ max-width: 800px;
+ margin: 0 auto;
+}
+
+.card {
+ background: var(--color-bg-base);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-6);
+ box-shadow: var(--shadow-sm);
+}
+
+.title {
+ font-size: var(--text-xl);
+ font-weight: 600;
+ margin-bottom: var(--space-4);
+ color: var(--color-text-strong);
+}
+
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+.field label {
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--color-text-subtle);
+}
+
+.tableWrapper {
+ overflow-x: auto;
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ text-align: left;
+}
+
+.table th, .table td {
+ padding: var(--space-3);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.table th {
+ font-weight: 600;
+ color: var(--color-text-subtle);
+ font-size: var(--text-sm);
+}
+
+.table td {
+ font-size: var(--text-sm);
+}
+
+.empty {
+ text-align: center;
+ color: var(--color-text-subtle);
+ padding: var(--space-6);
+}
+
+.roleBadge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: var(--text-xs);
+ font-weight: 600;
+ text-transform: capitalize;
+}
+
+.roleBadge[data-role="admin"] {
+ background: #fef2f2;
+ color: #991b1b;
+ border: 1px solid #fca5a5;
+}
+
+.roleBadge[data-role="agent"] {
+ background: #eff6ff;
+ color: #1e40af;
+ border: 1px solid #bfdbfe;
+}
+
+.roleBadge[data-role="requester"] {
+ background: #f3f4f6;
+ color: #374151;
+ border: 1px solid #d1d5db;
+}
+
+.modalOverlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ backdrop-filter: blur(2px);
+}
+
+.modalContent {
+ background: var(--color-bg-base);
+ border-radius: var(--radius-lg);
+ padding: var(--space-6);
+ width: 100%;
+ max-width: 500px;
+ box-shadow: var(--shadow-lg);
+}
diff --git a/web/src/pages/UsersPage.tsx b/web/src/pages/UsersPage.tsx
new file mode 100644
index 0000000..7406dcd
--- /dev/null
+++ b/web/src/pages/UsersPage.tsx
@@ -0,0 +1,290 @@
+import { useState, type FormEvent } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { api, type UserResponse } from '@/lib/api';
+import { useAuth } from '@/context/AuthContext';
+import { useToast } from '@/context/ToastContext';
+import styles from './UsersPage.module.css';
+
+export default function UsersPage() {
+ const { user } = useAuth();
+ const { addToast } = useToast();
+ const queryClient = useQueryClient();
+
+ const [name, setName] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [role, setRole] = useState<'admin' | 'agent' | 'requester'>('requester');
+
+ const [editingUser, setEditingUser] = useState<UserResponse | null>(null);
+ const [editName, setEditName] = useState('');
+ const [editEmail, setEditEmail] = useState('');
+ const [editPassword, setEditPassword] = useState('');
+ const [editRole, setEditRole] = useState<'admin' | 'agent' | 'requester'>('requester');
+ const [editIsActive, setEditIsActive] = useState(true);
+
+ const { data: users, isLoading } = useQuery({
+ queryKey: ['users'],
+ queryFn: () => api.users.list(),
+ enabled: user?.role === 'admin',
+ });
+
+ const createMutation = useMutation({
+ mutationFn: () => api.users.create({ name, email, password, role }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['users'] });
+ setName('');
+ setEmail('');
+ setPassword('');
+ setRole('requester');
+ addToast('Usuário criado com sucesso!', 'success');
+ },
+ onError: (error: any) => {
+ addToast(`Erro ao criar usuário: ${error.message}`, 'error');
+ },
+ });
+
+ const updateMutation = useMutation({
+ mutationFn: () => api.users.update(editingUser!.id, {
+ name: editName,
+ email: editEmail,
+ password: editPassword || undefined,
+ role: editRole,
+ isActive: editIsActive,
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['users'] });
+ setEditingUser(null);
+ addToast('Usuário atualizado com sucesso!', 'success');
+ },
+ onError: (error: any) => addToast(`Erro ao atualizar: ${error.message}`, 'error'),
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: number) => api.users.delete(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['users'] });
+ addToast('Usuário excluído com sucesso!', 'success');
+ },
+ onError: (error: any) => addToast(`Erro ao excluir: ${error.message}`, 'error'),
+ });
+
+ function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ createMutation.mutate();
+ }
+
+ function handleEditSubmit(e: FormEvent) {
+ e.preventDefault();
+ updateMutation.mutate();
+ }
+
+ function openEdit(u: UserResponse) {
+ setEditingUser(u);
+ setEditName(u.name);
+ setEditEmail(u.email);
+ setEditPassword('');
+ setEditRole(u.role);
+ setEditIsActive(u.isActive);
+ }
+
+ if (user?.role !== 'admin') {
+ return (
+ <div className={styles.container}>
+ <h2>Acesso Negado</h2>
+ <p>Apenas administradores podem gerenciar usuários.</p>
+ </div>
+ );
+ }
+
+ return (
+ <div className={styles.container}>
+ <div className={styles.card}>
+ <h2 className={styles.title}>Criar Novo Usuário</h2>
+ <form className={styles.form} onSubmit={handleSubmit}>
+ <div className={styles.field}>
+ <label htmlFor="user-name">Nome</label>
+ <input
+ id="user-name"
+ type="text"
+ className="input"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ required
+ />
+ </div>
+
+ <div className={styles.field}>
+ <label htmlFor="user-email">E-mail</label>
+ <input
+ id="user-email"
+ type="email"
+ className="input"
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ required
+ />
+ </div>
+
+ <div className={styles.field}>
+ <label htmlFor="user-pwd">Senha</label>
+ <input
+ id="user-pwd"
+ type="password"
+ className="input"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ required
+ minLength={8}
+ />
+ </div>
+
+ <div className={styles.field}>
+ <label htmlFor="user-role">Nível de Acesso</label>
+ <select
+ id="user-role"
+ className="input"
+ value={role}
+ onChange={(e) => setRole(e.target.value as any)}
+ >
+ <option value="requester">Usuário Padrão (Requester)</option>
+ <option value="agent">Agente (Agent)</option>
+ <option value="admin">Administrador (Admin)</option>
+ </select>
+ </div>
+
+ <button
+ type="submit"
+ className="btn btn--primary"
+ disabled={createMutation.isPending}
+ >
+ {createMutation.isPending ? 'Criando...' : 'Criar Usuário'}
+ </button>
+ </form>
+ </div>
+
+ <div className={styles.card}>
+ <h2 className={styles.title}>Usuários Cadastrados</h2>
+ {isLoading ? (
+ <p>Carregando...</p>
+ ) : (
+ <div className={styles.tableWrapper}>
+ <table className={styles.table}>
+ <thead>
+ <tr>
+ <th>Nome</th>
+ <th>E-mail</th>
+ <th>Role</th>
+ <th>Ativo</th>
+ <th style={{ textAlign: 'right' }}>Ações</th>
+ </tr>
+ </thead>
+ <tbody>
+ {users?.map((u) => (
+ <tr key={u.id}>
+ <td>{u.name}</td>
+ <td>{u.email}</td>
+ <td><span className={styles.roleBadge} data-role={u.role}>{u.role}</span></td>
+ <td>{u.isActive ? 'Sim' : 'Não'}</td>
+ <td style={{ textAlign: 'right' }}>
+ <button
+ className="btn btn--sm"
+ onClick={() => openEdit(u)}
+ style={{ marginRight: '8px', backgroundColor: '#3b82f6', color: '#ffffff', border: 'none' }}
+ >
+ Editar
+ </button>
+ <button
+ className="btn btn--sm btn--danger"
+ disabled={user?.id === u.id || deleteMutation.isPending}
+ onClick={() => {
+ if (confirm('Tem certeza que deseja excluir este usuário?')) {
+ deleteMutation.mutate(u.id);
+ }
+ }}
+ >
+ Excluir
+ </button>
+ </td>
+ </tr>
+ ))}
+ {users?.length === 0 && (
+ <tr>
+ <td colSpan={5} className={styles.empty}>Nenhum usuário encontrado.</td>
+ </tr>
+ )}
+ </tbody>
+ </table>
+ </div>
+ )}
+ </div>
+
+ {editingUser && (
+ <div className={styles.modalOverlay}>
+ <div className={styles.modalContent}>
+ <h2 className={styles.title}>Editar Usuário</h2>
+ <form className={styles.form} onSubmit={handleEditSubmit}>
+ <div className={styles.field}>
+ <label>Nome</label>
+ <input
+ type="text"
+ className="input"
+ value={editName}
+ onChange={(e) => setEditName(e.target.value)}
+ required
+ />
+ </div>
+ <div className={styles.field}>
+ <label>E-mail</label>
+ <input
+ type="email"
+ className="input"
+ value={editEmail}
+ onChange={(e) => setEditEmail(e.target.value)}
+ required
+ />
+ </div>
+ <div className={styles.field}>
+ <label>Nova Senha (deixe em branco para não alterar)</label>
+ <input
+ type="password"
+ className="input"
+ value={editPassword}
+ onChange={(e) => setEditPassword(e.target.value)}
+ minLength={8}
+ />
+ </div>
+ <div className={styles.field}>
+ <label>Nível de Acesso</label>
+ <select
+ className="input"
+ value={editRole}
+ onChange={(e) => setEditRole(e.target.value as any)}
+ >
+ <option value="requester">Usuário Padrão (Requester)</option>
+ <option value="agent">Agente (Agent)</option>
+ <option value="admin">Administrador (Admin)</option>
+ </select>
+ </div>
+ <div className={styles.field} style={{ flexDirection: 'row', alignItems: 'center', gap: '8px', marginTop: '8px' }}>
+ <input
+ id="user-active"
+ type="checkbox"
+ checked={editIsActive}
+ onChange={(e) => setEditIsActive(e.target.checked)}
+ />
+ <label htmlFor="user-active" style={{ marginBottom: 0 }}>Usuário Ativo</label>
+ </div>
+
+ <div style={{ display: 'flex', gap: '8px', marginTop: '16px', justifyContent: 'flex-end' }}>
+ <button type="button" className="btn" onClick={() => setEditingUser(null)}>Cancelar</button>
+ <button type="submit" className="btn btn--primary" disabled={updateMutation.isPending}>
+ {updateMutation.isPending ? 'Salvando...' : 'Salvar'}
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/web/src/router.tsx b/web/src/router.tsx
new file mode 100644
index 0000000..5105fec
--- /dev/null
+++ b/web/src/router.tsx
@@ -0,0 +1,70 @@
+/**
+ * router.tsx
+ * Rotas da aplicação com proteção por autenticação.
+ */
+
+import { createBrowserRouter, Navigate } from 'react-router-dom';
+import { lazy, Suspense } from 'react';
+import AppLayout from '@/components/layout/AppLayout';
+import PageLoader from '@/components/ui/PageLoader';
+import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
+
+// ─── Lazy pages ───────────────────────────────────────────────────────────────
+const LoginPage = lazy(() => import('@/pages/LoginPage'));
+const DashboardPage = lazy(() => import('@/pages/DashboardPage'));
+const NewTicketPage = lazy(() => import('@/pages/NewTicketPage'));
+const TicketDetailPage = lazy(() => import('@/pages/TicketDetailPage'));
+const UsersPage = lazy(() => import('@/pages/UsersPage'));
+const SettingsPage = lazy(() => import('@/pages/SettingsPage'));
+
+function Lazy({ children }: { children: React.ReactNode }) {
+ return <Suspense fallback={<PageLoader />}>{children}</Suspense>;
+}
+
+// ─── Router ───────────────────────────────────────────────────────────────────
+export const router = createBrowserRouter([
+ // ── Rota pública: login (sem AppLayout) ───────────────────────────────────
+ {
+ path: '/login',
+ element: <Lazy><LoginPage /></Lazy>,
+ },
+
+ // ── Rotas protegidas (requerem autenticação) ──────────────────────────────
+ {
+ path: '/',
+ element: (
+ <ProtectedRoute>
+ <AppLayout />
+ </ProtectedRoute>
+ ),
+ children: [
+ {
+ index: true,
+ element: <Navigate to="/dashboard" replace />,
+ },
+ {
+ path: 'dashboard',
+ element: <Lazy><DashboardPage /></Lazy>,
+ },
+ {
+ path: 'tickets/new',
+ element: <Lazy><NewTicketPage /></Lazy>,
+ },
+ {
+ path: 'tickets/:id',
+ element: <Lazy><TicketDetailPage /></Lazy>,
+ },
+ {
+ path: 'users',
+ element: <Lazy><UsersPage /></Lazy>,
+ },
+ {
+ path: 'settings',
+ element: <Lazy><SettingsPage /></Lazy>,
+ },
+ ],
+ },
+
+ // Fallback
+ { path: '*', element: <Navigate to="/dashboard" replace /> },
+]);
diff --git a/web/src/styles/index.css b/web/src/styles/index.css
new file mode 100644
index 0000000..8d8a3ec
--- /dev/null
+++ b/web/src/styles/index.css
@@ -0,0 +1,274 @@
+/* ============================================================================
+ HELPDESK TI — Design System Global
+ Paleta: azul profissional escuro + acentos em indigo
+ ============================================================================ */
+
+/* ─── Google Fonts fallback ───────────────────────────────────────────────── */
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
+
+/* ─── Tokens de Design ────────────────────────────────────────────────────── */
+:root {
+ /* Cores — Superfícies */
+ --color-bg: #0f1117;
+ --color-surface: #1a1d27;
+ --color-surface-2: #222638;
+ --color-border: #2e3348;
+ --color-border-hover: #454d6e;
+
+ /* Cores — Texto */
+ --color-text-primary: #e8eaf0;
+ --color-text-secondary: #8b92a8;
+ --color-text-muted: #555e7a;
+
+ /* Cores — Acento (Indigo) */
+ --color-accent: #6c7ef8;
+ --color-accent-hover: #8290fa;
+ --color-accent-subtle: rgba(108, 126, 248, 0.12);
+
+ /* Cores — Status dos Tickets */
+ --color-open: #60a5fa; /* Azul */
+ --color-in-progress: #a78bfa; /* Roxo */
+ --color-waiting: #f59e0b; /* Âmbar */
+ --color-resolved: #34d399; /* Verde */
+ --color-closed: #6b7280; /* Cinza */
+
+ /* Cores — Prioridade */
+ --color-priority-low: #34d399;
+ --color-priority-medium: #60a5fa;
+ --color-priority-high: #f59e0b;
+ --color-priority-critical: #f87171;
+
+ /* Tipografia */
+ --font-sans: 'Inter', system-ui, -apple-system, sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
+
+ --text-xs: 0.75rem;
+ --text-sm: 0.875rem;
+ --text-base: 1rem;
+ --text-lg: 1.125rem;
+ --text-xl: 1.25rem;
+ --text-2xl: 1.5rem;
+ --text-3xl: 1.875rem;
+
+ /* Espaçamento */
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --space-12: 3rem;
+ --space-16: 4rem;
+
+ /* Border Radius */
+ --radius-sm: 0.375rem;
+ --radius-md: 0.5rem;
+ --radius-lg: 0.75rem;
+ --radius-xl: 1rem;
+ --radius-full: 9999px;
+
+ /* Sombras */
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
+ --shadow-accent: 0 0 0 3px rgba(108, 126, 248, 0.3);
+
+ /* Transições */
+ --transition-fast: 150ms ease;
+ --transition-normal: 250ms ease;
+ --transition-slow: 400ms ease;
+
+ /* Layout */
+ --sidebar-width: 260px;
+ --header-height: 60px;
+ --content-max-w: 1200px;
+}
+
+/* ─── Reset Moderno ───────────────────────────────────────────────────────── */
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html {
+ font-size: 16px;
+ scroll-behavior: smooth;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ font-family: var(--font-sans);
+ font-size: var(--text-base);
+ color: var(--color-text-primary);
+ background-color: var(--color-bg);
+ line-height: 1.6;
+ min-height: 100vh;
+}
+
+img, video {
+ max-width: 100%;
+ display: block;
+}
+
+button {
+ cursor: pointer;
+ font-family: inherit;
+ border: none;
+ background: none;
+}
+
+input, textarea, select {
+ font-family: inherit;
+ font-size: inherit;
+}
+
+a {
+ color: var(--color-accent);
+ text-decoration: none;
+ transition: color var(--transition-fast);
+}
+
+a:hover {
+ color: var(--color-accent-hover);
+}
+
+/* ─── Scrollbar customizada ───────────────────────────────────────────────── */
+::-webkit-scrollbar { width: 6px; height: 6px; }
+::-webkit-scrollbar-track { background: var(--color-surface); }
+::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: var(--radius-full); }
+::-webkit-scrollbar-thumb:hover { background: var(--color-border-hover); }
+
+/* ─── Classes utilitárias ─────────────────────────────────────────────────── */
+
+/* Badges de status dos tickets */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: 2px var(--space-2);
+ border-radius: var(--radius-full);
+ font-size: var(--text-xs);
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+
+.badge--open { background: rgba(96, 165, 250, 0.15); color: var(--color-open); }
+.badge--in_progress { background: rgba(167, 139, 250, 0.15); color: var(--color-in-progress); }
+.badge--waiting { background: rgba(245, 158, 11, 0.15); color: var(--color-waiting); }
+.badge--resolved { background: rgba(52, 211, 153, 0.15); color: var(--color-resolved); }
+.badge--closed { background: rgba(107, 114, 128, 0.15); color: var(--color-closed); }
+
+/* Badges de prioridade */
+.badge--low { background: rgba(52, 211, 153, 0.15); color: var(--color-priority-low); }
+.badge--medium { background: rgba(96, 165, 250, 0.15); color: var(--color-priority-medium); }
+.badge--high { background: rgba(245, 158, 11, 0.15); color: var(--color-priority-high); }
+.badge--critical { background: rgba(248, 113, 113, 0.15); color: var(--color-priority-critical); }
+
+/* Botões */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ border-radius: var(--radius-md);
+ font-size: var(--text-sm);
+ font-weight: 500;
+ transition: all var(--transition-fast);
+ white-space: nowrap;
+}
+
+.btn--primary {
+ background: var(--color-accent);
+ color: #fff;
+}
+.btn--primary:hover {
+ background: var(--color-accent-hover);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+.btn--ghost {
+ background: transparent;
+ color: var(--color-text-secondary);
+ border: 1px solid var(--color-border);
+}
+.btn--ghost:hover {
+ background: var(--color-surface-2);
+ color: var(--color-text-primary);
+ border-color: var(--color-border-hover);
+}
+
+.btn--danger {
+ background: rgba(248, 113, 113, 0.15);
+ color: var(--color-priority-critical);
+ border: 1px solid rgba(248, 113, 113, 0.3);
+}
+.btn--danger:hover {
+ background: rgba(248, 113, 113, 0.25);
+}
+
+/* Inputs */
+.input {
+ width: 100%;
+ padding: var(--space-2) var(--space-3);
+ background: var(--color-surface-2);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ color: var(--color-text-primary);
+ font-size: var(--text-sm);
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
+ outline: none;
+}
+
+.input:focus {
+ border-color: var(--color-accent);
+ box-shadow: var(--shadow-accent);
+}
+
+.input::placeholder {
+ color: var(--color-text-muted);
+}
+
+/* Cards */
+.card {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-5);
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
+}
+
+.card:hover {
+ border-color: var(--color-border-hover);
+ box-shadow: var(--shadow-md);
+}
+
+/* Animações */
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+.animate-fade-in {
+ animation: fadeIn var(--transition-normal) ease forwards;
+}
+
+/* Loading spinner */
+.spinner {
+ width: 20px;
+ height: 20px;
+ border: 2px solid var(--color-border);
+ border-top-color: var(--color-accent);
+ border-radius: 50%;
+ animation: spin 0.7s linear infinite;
+}