aboutsummaryrefslogtreecommitdiff
path: root/web/src/pages/DashboardPage.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'web/src/pages/DashboardPage.tsx')
-rw-r--r--web/src/pages/DashboardPage.tsx224
1 files changed, 224 insertions, 0 deletions
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>
+ );
+}