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/src/components | |
| download | helpdesk-ef55d9bb24430610b60c1b77821307cd72c08c55.tar.gz helpdesk-ef55d9bb24430610b60c1b77821307cd72c08c55.zip | |
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
Diffstat (limited to 'web/src/components')
| -rw-r--r-- | web/src/components/auth/ProtectedRoute.tsx | 20 | ||||
| -rw-r--r-- | web/src/components/layout/AppLayout.module.css | 194 | ||||
| -rw-r--r-- | web/src/components/layout/AppLayout.tsx | 199 | ||||
| -rw-r--r-- | web/src/components/tickets/PriorityBadge.tsx | 25 | ||||
| -rw-r--r-- | web/src/components/tickets/StatusBadge.tsx | 26 | ||||
| -rw-r--r-- | web/src/components/tickets/TicketCard.module.css | 94 | ||||
| -rw-r--r-- | web/src/components/tickets/TicketCard.tsx | 66 | ||||
| -rw-r--r-- | web/src/components/ui/PageLoader.module.css | 26 | ||||
| -rw-r--r-- | web/src/components/ui/PageLoader.tsx | 28 |
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> + ); +} |