aboutsummaryrefslogtreecommitdiff
path: root/web/src/components
diff options
context:
space:
mode:
authorzwlucas <lucas.fariamo08@gmail.com>2026-07-25 21:23:29 +0000
committerzwlucas <lucas.fariamo08@gmail.com>2026-07-25 21:23:29 +0000
commitef55d9bb24430610b60c1b77821307cd72c08c55 (patch)
treee6935a810b34370b0a4c821e55f6397aac50c832 /web/src/components
downloadhelpdesk-master.tar.gz
helpdesk-master.zip
feat: upload of the projectHEADmaster
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'web/src/components')
-rw-r--r--web/src/components/auth/ProtectedRoute.tsx20
-rw-r--r--web/src/components/layout/AppLayout.module.css194
-rw-r--r--web/src/components/layout/AppLayout.tsx199
-rw-r--r--web/src/components/tickets/PriorityBadge.tsx25
-rw-r--r--web/src/components/tickets/StatusBadge.tsx26
-rw-r--r--web/src/components/tickets/TicketCard.module.css94
-rw-r--r--web/src/components/tickets/TicketCard.tsx66
-rw-r--r--web/src/components/ui/PageLoader.module.css26
-rw-r--r--web/src/components/ui/PageLoader.tsx28
9 files changed, 678 insertions, 0 deletions
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>
+ );
+}