aboutsummaryrefslogtreecommitdiff
path: root/web/src/pages
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/pages
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/pages')
-rw-r--r--web/src/pages/DashboardPage.module.css160
-rw-r--r--web/src/pages/DashboardPage.tsx224
-rw-r--r--web/src/pages/LoginPage.module.css125
-rw-r--r--web/src/pages/LoginPage.tsx124
-rw-r--r--web/src/pages/NewTicketPage.module.css88
-rw-r--r--web/src/pages/NewTicketPage.tsx203
-rw-r--r--web/src/pages/SettingsPage.module.css111
-rw-r--r--web/src/pages/SettingsPage.tsx63
-rw-r--r--web/src/pages/TicketDetailPage.module.css263
-rw-r--r--web/src/pages/TicketDetailPage.tsx366
-rw-r--r--web/src/pages/UsersPage.module.css121
-rw-r--r--web/src/pages/UsersPage.tsx290
12 files changed, 2138 insertions, 0 deletions
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>
+ );
+}