From ef55d9bb24430610b60c1b77821307cd72c08c55 Mon Sep 17 00:00:00 2001 From: zwlucas Date: Sat, 25 Jul 2026 18:23:29 -0300 Subject: feat: upload of the project Signed-off-by: zwlucas --- web/index.html | 27 ++ web/package.json | 37 +++ web/src/App.tsx | 39 +++ web/src/components/auth/ProtectedRoute.tsx | 20 ++ web/src/components/layout/AppLayout.module.css | 194 ++++++++++++ web/src/components/layout/AppLayout.tsx | 199 ++++++++++++ web/src/components/tickets/PriorityBadge.tsx | 25 ++ web/src/components/tickets/StatusBadge.tsx | 26 ++ web/src/components/tickets/TicketCard.module.css | 94 ++++++ web/src/components/tickets/TicketCard.tsx | 66 ++++ web/src/components/ui/PageLoader.module.css | 26 ++ web/src/components/ui/PageLoader.tsx | 28 ++ web/src/context/AuthContext.tsx | 79 +++++ web/src/context/Toast.module.css | 75 +++++ web/src/context/ToastContext.tsx | 71 +++++ web/src/hooks/useTicketEvents.ts | 91 ++++++ web/src/lib/api.ts | 297 ++++++++++++++++++ web/src/main.tsx | 16 + web/src/pages/DashboardPage.module.css | 160 ++++++++++ web/src/pages/DashboardPage.tsx | 224 ++++++++++++++ web/src/pages/LoginPage.module.css | 125 ++++++++ web/src/pages/LoginPage.tsx | 124 ++++++++ web/src/pages/NewTicketPage.module.css | 88 ++++++ web/src/pages/NewTicketPage.tsx | 203 +++++++++++++ web/src/pages/SettingsPage.module.css | 111 +++++++ web/src/pages/SettingsPage.tsx | 63 ++++ web/src/pages/TicketDetailPage.module.css | 263 ++++++++++++++++ web/src/pages/TicketDetailPage.tsx | 366 +++++++++++++++++++++++ web/src/pages/UsersPage.module.css | 121 ++++++++ web/src/pages/UsersPage.tsx | 290 ++++++++++++++++++ web/src/router.tsx | 70 +++++ web/src/styles/index.css | 274 +++++++++++++++++ web/tsconfig.app.json | 32 ++ web/tsconfig.json | 7 + web/tsconfig.node.json | 12 + web/vite.config.ts | 33 ++ 36 files changed, 3976 insertions(+) create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/App.tsx create mode 100644 web/src/components/auth/ProtectedRoute.tsx create mode 100644 web/src/components/layout/AppLayout.module.css create mode 100644 web/src/components/layout/AppLayout.tsx create mode 100644 web/src/components/tickets/PriorityBadge.tsx create mode 100644 web/src/components/tickets/StatusBadge.tsx create mode 100644 web/src/components/tickets/TicketCard.module.css create mode 100644 web/src/components/tickets/TicketCard.tsx create mode 100644 web/src/components/ui/PageLoader.module.css create mode 100644 web/src/components/ui/PageLoader.tsx create mode 100644 web/src/context/AuthContext.tsx create mode 100644 web/src/context/Toast.module.css create mode 100644 web/src/context/ToastContext.tsx create mode 100644 web/src/hooks/useTicketEvents.ts create mode 100644 web/src/lib/api.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/pages/DashboardPage.module.css create mode 100644 web/src/pages/DashboardPage.tsx create mode 100644 web/src/pages/LoginPage.module.css create mode 100644 web/src/pages/LoginPage.tsx create mode 100644 web/src/pages/NewTicketPage.module.css create mode 100644 web/src/pages/NewTicketPage.tsx create mode 100644 web/src/pages/SettingsPage.module.css create mode 100644 web/src/pages/SettingsPage.tsx create mode 100644 web/src/pages/TicketDetailPage.module.css create mode 100644 web/src/pages/TicketDetailPage.tsx create mode 100644 web/src/pages/UsersPage.module.css create mode 100644 web/src/pages/UsersPage.tsx create mode 100644 web/src/router.tsx create mode 100644 web/src/styles/index.css create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts (limited to 'web') 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 @@ + + + + + + + + Helpdesk TI — Central de Chamados + + + + + + + + + + + + +
+ + + 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 ( + + + + + + + + ); +} 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 ; + } + + 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 . + */ + +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: () => ( + + ), + Plus: () => ( + + ), + Ticket: () => ( + + ), + Logo: () => ( + + ), + User: () => ( + + ), + Users: () => ( + + ), + Settings: () => ( + + ), +}; + +// ─── Mapa de títulos por rota ───────────────────────────────────────────────── +const ROUTE_TITLES: Record = { + '/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 ( +
+ + {/* ── Sidebar ─────────────────────────────────────────────────────── */} + + + {/* ── Área principal ───────────────────────────────────────────────── */} +
+ + {/* Header */} +
+

{pageTitle}

+
+ + + Novo Chamado + +
+
+ + {/* Conteúdo da página */} +
+ +
+
+
+ ); +} 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 = { + low: '↓ Baixa', + medium: '→ Média', + high: '↑ Alta', + critical: '⚡ Crítica', +}; + +interface PriorityBadgeProps { + priority: TicketPriority; +} + +export function PriorityBadge({ priority }: PriorityBadgeProps) { + return ( + + {PRIORITY_LABELS[priority]} + + ); +} 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 = { + open: 'Aberto', + in_progress: 'Em Andamento', + waiting: 'Aguardando', + resolved: 'Resolvido', + closed: 'Fechado', +}; + +interface StatusBadgeProps { + status: TicketStatus; +} + +export function StatusBadge({ status }: StatusBadgeProps) { + return ( + + {STATUS_LABELS[status]} + + ); +} 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 ( +
navigate(`/tickets/${ticket.id}`)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && navigate(`/tickets/${ticket.id}`)} + aria-label={`Ticket #${ticket.id}: ${ticket.title}`} + > +
+
+ + +
+ #{ticket.id} +
+ +

{ticket.title}

+ +

{ticket.description}

+ +
+ + Aberto em {formatDate(ticket.createdAt)} + + {ticket.assigneeId ? ( + + + Agente #{ticket.assigneeId} + + ) : ( + Sem agente + )} +
+
+ ); +} 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 ( +
+
+
+ + Helpdesk TI +
+
+
+
+ ); +} 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(null); + +// ─── Provider ───────────────────────────────────────────────────────────────── + +export function AuthProvider({ children }: { children: ReactNode }) { + // Inicializa com dados persistidos (restaura sessão ao recarregar) + const [token, setToken] = useState( + () => localStorage.getItem(TOKEN_KEY), + ); + const [user, setUser] = useState(() => { + 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 ( + + {children} + + ); +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +export function useAuth(): AuthState { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error('useAuth deve ser usado dentro de '); + } + 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(null); + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + + 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 ( + + {children} + + {/* Portal renderiza os toasts no final do */} + {createPortal( +
+ {toasts.map((toast) => ( +
+ {toast.message} + +
+ ))} +
, + document.body + )} +
+ ); +} + +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 { + 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( + path: string, + options?: RequestInit, +): Promise { + const token = getToken(); + const headers: Record = { ...options?.headers as Record }; + + 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; +} + +// ─── 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 { + return request('/settings'); + }, + update(data: { allowRequesterChat: boolean }): Promise { + return request('/settings', { method: 'PATCH', body: JSON.stringify(data) }); + } + }, + + stats: { + get(): Promise { + return request('/stats'); + }, + }, + + auth: { + login(data: { email: string; password: string }): Promise { + return request('/auth/login', { method: 'POST', body: JSON.stringify(data) }); + }, + register(data: { name: string; email: string; password: string; role?: string }): Promise { + return request('/auth/register', { method: 'POST', body: JSON.stringify(data) }); + }, + me(): Promise { + return request('/auth/me'); + }, + }, + + categories: { + list(): Promise { + return request('/categories'); + }, + get(id: number): Promise { + return request(`/categories/${id}`); + }, + create(data: { name: string; slug?: string; description?: string }): Promise { + return request('/categories', { method: 'POST', body: JSON.stringify(data) }); + }, + update(id: number, data: { name?: string; slug?: string; description?: string }): Promise { + return request(`/categories/${id}`, { method: 'PATCH', body: JSON.stringify(data) }); + }, + delete(id: number): Promise { + return request(`/categories/${id}`, { method: 'DELETE' }); + } + }, + + tickets: { + list(params: ListTicketsParams = {}): Promise> { + 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 { + return request(`/tickets/${id}`); + }, + + create(data: { + title: string; + description: string; + priority?: TicketPriority; + categoryId?: number; + creatorId: number; + }): Promise { + return request('/tickets', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + updateStatus(ticketId: number, status: TicketStatus, reason?: string): Promise { + return request(`/tickets/${ticketId}/status`, { + method: 'PATCH', + body: JSON.stringify({ status, reason }), + }); + }, + + assign(ticketId: number, assigneeId: number): Promise { + return request(`/tickets/${ticketId}/assign`, { + method: 'PATCH', + body: JSON.stringify({ assigneeId }), + }); + }, + + updatePriority(ticketId: number, priority: TicketPriority): Promise { + return request(`/tickets/${ticketId}/priority`, { + method: 'PATCH', + body: JSON.stringify({ priority }), + }); + }, + + delete(id: number): Promise { + return request(`/tickets/${id}`, { method: 'DELETE' }); + }, + }, + + comments: { + list(ticketId: number): Promise { + return request(`/tickets/${ticketId}/comments`); + }, + + add(ticketId: number, data: { + content: string; + isInternal: boolean; + authorId: number; + }): Promise { + return request(`/tickets/${ticketId}/comments`, { + method: 'POST', + body: JSON.stringify(data), + }); + }, + }, + + users: { + list(): Promise { + return request('/users'); + }, + create(data: { name: string; email: string; password: string; role: 'admin' | 'agent' | 'requester' }): Promise { + 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 { + return request(`/users/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + }, + delete(id: number): Promise { + 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( + + + , +); 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 ( +