diff options
Diffstat (limited to 'web/src/pages/NewTicketPage.tsx')
| -rw-r--r-- | web/src/pages/NewTicketPage.tsx | 203 |
1 files changed, 203 insertions, 0 deletions
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> + ); +} |