diff options
| author | zwlucas <lucas.fariamo08@gmail.com> | 2026-07-25 21:23:29 +0000 |
|---|---|---|
| committer | zwlucas <lucas.fariamo08@gmail.com> | 2026-07-25 21:23:29 +0000 |
| commit | ef55d9bb24430610b60c1b77821307cd72c08c55 (patch) | |
| tree | e6935a810b34370b0a4c821e55f6397aac50c832 /web | |
| download | helpdesk-master.tar.gz helpdesk-master.zip | |
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'web')
36 files changed, 3976 insertions, 0 deletions
diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..982c17a --- /dev/null +++ b/web/index.html @@ -0,0 +1,27 @@ +<!doctype html> +<html lang="pt-BR"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + + <!-- SEO --> + <title>Helpdesk TI — Central de Chamados</title> + <meta name="description" content="Sistema interno de abertura e acompanhamento de chamados de suporte de TI." /> + <meta name="robots" content="noindex, nofollow" /><!-- Aplicação interna --> + + <!-- Favicon --> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + + <!-- Tipografia: Inter via Google Fonts --> + <link rel="preconnect" href="https://fonts.googleapis.com" /> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> + <link + href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" + rel="stylesheet" + /> + </head> + <body> + <div id="root"></div> + <script type="module" src="/src/main.tsx"></script> + </body> +</html> diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..9dba0fc --- /dev/null +++ b/web/package.json @@ -0,0 +1,37 @@ +{ + "name": "@helpdesk/web", + "version": "1.0.0", + "description": "Frontend do Sistema de Chamados de TI — React + TypeScript + Vite", + "private": true, + "type": "module", + "engines": { + "node": ">=20.0.0" + }, + + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + }, + + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.24.1", + "@tanstack/react-query": "^5.51.1" + }, + + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.3", + "vite": "^5.3.4", + "@typescript-eslint/eslint-plugin": "^7.16.0", + "@typescript-eslint/parser": "^7.16.0", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.7" + } +} 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" + > + × + </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; +} diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..edabd23 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + + // Strict + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + + // Interop + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + + // Path aliases: import { x } from '@/components/...' + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + + // Não emite arquivos (Vite cuida do bundling) + "noEmit": true + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..ba5ccc4 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.app.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..88d4c69 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..980f48c --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + plugins: [react()], + + resolve: { + alias: { + // Permite: import { api } from '@/lib/api' + '@': path.resolve(__dirname, './src'), + }, + }, + + server: { + port: 5173, + proxy: { + // Redireciona /api/* para o servidor Fastify em desenvolvimento + // Elimina problemas de CORS — o browser só fala com localhost:5173 + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + '/health': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, +}); |