diff --git a/apps/web/src/lib/components/MenuProtection.svelte b/apps/web/src/lib/components/MenuProtection.svelte index e51a057..ce9783f 100644 --- a/apps/web/src/lib/components/MenuProtection.svelte +++ b/apps/web/src/lib/components/MenuProtection.svelte @@ -52,8 +52,8 @@ }); function verificarPermissoes() { - // Dashboard e Solicitar Acesso são públicos - if (menuPath === "/" || menuPath === "/solicitar-acesso") { + // Dashboard e abertura de chamados são públicos + if (menuPath === "/" || menuPath === "/abrir-chamado") { verificando = false; temPermissao = true; return; diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index 1d1e1cc..fbc1386 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -376,11 +376,11 @@ {/each}
  • - Solicitar acesso + Abrir Chamado
  • @@ -460,11 +460,11 @@
    - Não tem acesso? Solicite aqui + Abrir Chamado + import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel"; + import { + corPrazo, + formatarData, + getStatusBadge, + getStatusDescription, + getStatusLabel, + prazoRestante, + } from "$lib/utils/chamados"; + import { createEventDispatcher } from "svelte"; + + type Ticket = Doc<"tickets">; + + interface Props { + ticket: Ticket; + selected?: boolean; + } + +const dispatch = createEventDispatcher<{ select: { ticketId: Id<"tickets"> } }>(); +const props = $props(); +const ticket = $derived(props.ticket); +const selected = $derived(props.selected ?? false); + + const prioridadeClasses: Record = { + baixa: "badge badge-sm bg-base-200 text-base-content/70", + media: "badge badge-sm badge-info badge-outline", + alta: "badge badge-sm badge-warning", + critica: "badge badge-sm badge-error", + }; + + function handleSelect() { + dispatch("select", { ticketId: ticket._id }); + } + + function getPrazoBadges() { + const badges: Array<{ label: string; classe: string }> = []; + if (ticket.prazoResposta) { + const cor = corPrazo(ticket.prazoResposta); + badges.push({ + label: `Resposta ${prazoRestante(ticket.prazoResposta) ?? ""}`, + classe: `badge badge-xs ${ + cor === "error" ? "badge-error" : cor === "warning" ? "badge-warning" : "badge-success" + }`, + }); + } + if (ticket.prazoConclusao) { + const cor = corPrazo(ticket.prazoConclusao); + badges.push({ + label: `Conclusão ${prazoRestante(ticket.prazoConclusao) ?? ""}`, + classe: `badge badge-xs ${ + cor === "error" ? "badge-error" : cor === "warning" ? "badge-warning" : "badge-success" + }`, + }); + } + return badges; + } + + +
    + +
    + diff --git a/apps/web/src/lib/components/chamados/TicketForm.svelte b/apps/web/src/lib/components/chamados/TicketForm.svelte new file mode 100644 index 0000000..ef2779b --- /dev/null +++ b/apps/web/src/lib/components/chamados/TicketForm.svelte @@ -0,0 +1,249 @@ + + +
    +
    +
    + + + {#if errors.titulo} + {errors.titulo} + {/if} +
    + +
    + +
    + {#each ["chamado", "reclamacao", "elogio", "sugestao"] as opcao} + + {/each} +
    +
    + +
    + + +
    + +
    + + + {#if errors.categoria} + {errors.categoria} + {/if} +
    + +
    + + +
    +
    + +
    + + + {#if errors.descricao} + {errors.descricao} + {/if} +
    + +
    +
    +
    +

    Anexos (opcional)

    +

    + Suporte a PDF e imagens (máx. 10MB por arquivo) +

    +
    + +
    + + {#if anexos.length > 0} +
    + {#each anexos as file, index (file.name + index)} +
    +
    +

    {file.name}

    +

    + {(file.size / 1024 / 1024).toFixed(2)} MB • {file.type} +

    +
    + +
    + {/each} +
    + {:else} +
    + Nenhum arquivo selecionado. +
    + {/if} +
    + +
    + + +
    +
    + diff --git a/apps/web/src/lib/components/chamados/TicketTimeline.svelte b/apps/web/src/lib/components/chamados/TicketTimeline.svelte new file mode 100644 index 0000000..07ce639 --- /dev/null +++ b/apps/web/src/lib/components/chamados/TicketTimeline.svelte @@ -0,0 +1,86 @@ + + +
    + {#if timeline.length === 0} +
    + Nenhuma etapa registrada ainda. +
    + {:else} + {#each timeline as entry (entry.etapa + entry.prazo)} +
    +
    +
    + {formatarTimelineEtapa(entry.etapa)} +
    + {#if entry !== timeline[timeline.length - 1]} +
    + {/if} +
    +
    +
    + + {getStatusLabel(entry)} + + {#if entry.status !== "concluido" && entry.prazo} + + {prazoRestante(entry.prazo)} + + {/if} +
    + {#if entry.observacao} +

    {entry.observacao}

    + {/if} +

    + {getPrazoDescricao(entry)} +

    +
    +
    + {/each} + {/if} +
    + diff --git a/apps/web/src/lib/stores/chamados.ts b/apps/web/src/lib/stores/chamados.ts new file mode 100644 index 0000000..acf1d74 --- /dev/null +++ b/apps/web/src/lib/stores/chamados.ts @@ -0,0 +1,53 @@ +import { writable } from "svelte/store"; +import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel"; + +export type TicketDetalhe = { + ticket: Doc<"tickets">; + interactions: Doc<"ticketInteractions">[]; +}; + +function createChamadosStore() { + const tickets = writable>>([]); + const detalhes = writable>({}); + const carregando = writable(false); + + function setTickets(lista: Array>) { + tickets.set(lista); + } + + function upsertTicket(ticket: Doc<"tickets">) { + tickets.update((current) => { + const existente = current.findIndex((t) => t._id === ticket._id); + if (existente >= 0) { + const copia = [...current]; + copia[existente] = ticket; + return copia; + } + return [ticket, ...current]; + }); + } + + function setDetalhe(ticketId: Id<"tickets">, detalhe: TicketDetalhe) { + detalhes.update((mapa) => ({ + ...mapa, + [ticketId]: detalhe, + })); + } + + function setCarregando(flag: boolean) { + carregando.set(flag); + } + + return { + tickets, + detalhes, + carregando, + setTickets, + upsertTicket, + setDetalhe, + setCarregando, + }; +} + +export const chamadosStore = createChamadosStore(); + diff --git a/apps/web/src/lib/utils/chamados.ts b/apps/web/src/lib/utils/chamados.ts new file mode 100644 index 0000000..d1aa518 --- /dev/null +++ b/apps/web/src/lib/utils/chamados.ts @@ -0,0 +1,123 @@ +import type { Doc } from "@sgse-app/backend/convex/_generated/dataModel"; + +type Ticket = Doc<"tickets">; +type TicketStatus = Ticket["status"]; +type TimelineEntry = NonNullable[number]; + +const UM_DIA_MS = 24 * 60 * 60 * 1000; + +const statusConfig: Record< + TicketStatus, + { + label: string; + badge: string; + description: string; + } +> = { + aberto: { + label: "Aberto", + badge: "badge badge-info badge-outline", + description: "Chamado recebido e aguardando triagem.", + }, + em_andamento: { + label: "Em andamento", + badge: "badge badge-primary", + description: "Equipe de TI trabalhando no chamado.", + }, + aguardando_usuario: { + label: "Aguardando usuário", + badge: "badge badge-warning", + description: "Aguardando retorno ou aprovação do solicitante.", + }, + resolvido: { + label: "Resolvido", + badge: "badge badge-success badge-outline", + description: "Solução aplicada, aguardando confirmação.", + }, + encerrado: { + label: "Encerrado", + badge: "badge badge-success", + description: "Chamado finalizado.", + }, + cancelado: { + label: "Cancelado", + badge: "badge badge-neutral", + description: "Chamado cancelado.", + }, +}; + +export function getStatusLabel(status: TicketStatus): string { + return statusConfig[status]?.label ?? status; +} + +export function getStatusBadge(status: TicketStatus): string { + return statusConfig[status]?.badge ?? "badge"; +} + +export function getStatusDescription(status: TicketStatus): string { + return statusConfig[status]?.description ?? ""; +} + +export function formatarData(timestamp?: number | null) { + if (!timestamp) return "--"; + return new Date(timestamp).toLocaleString("pt-BR", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function prazoRestante(timestamp?: number | null) { + if (!timestamp) return null; + const diff = timestamp - Date.now(); + const dias = Math.floor(diff / UM_DIA_MS); + const horas = Math.floor((diff % UM_DIA_MS) / (60 * 60 * 1000)); + + if (diff < 0) { + return `Vencido há ${Math.abs(dias)}d ${Math.abs(horas)}h`; + } + + if (dias === 0 && horas >= 0) { + return `Vence em ${horas}h`; + } + + return `Vence em ${dias}d ${Math.abs(horas)}h`; +} + +export function corPrazo(timestamp?: number | null) { + if (!timestamp) return "info"; + const diff = timestamp - Date.now(); + if (diff < 0) return "error"; + if (diff <= UM_DIA_MS) return "warning"; + return "success"; +} + +export function timelineStatus(entry: TimelineEntry) { + if (entry.status === "concluido") { + return "success"; + } + if (!entry.prazo) { + return "info"; + } + const diff = entry.prazo - Date.now(); + if (diff < 0) { + return "error"; + } + if (diff <= UM_DIA_MS) { + return "warning"; + } + return "info"; +} + +export function formatarTimelineEtapa(etapa: string) { + const mapa: Record = { + abertura: "Registro", + resposta_inicial: "Resposta inicial", + conclusao: "Conclusão", + encerramento: "Encerramento", + }; + + return mapa[etapa] ?? etapa; +} + diff --git a/apps/web/src/routes/(dashboard)/+layout.svelte b/apps/web/src/routes/(dashboard)/+layout.svelte index 3e3778a..c8b1523 100644 --- a/apps/web/src/routes/(dashboard)/+layout.svelte +++ b/apps/web/src/routes/(dashboard)/+layout.svelte @@ -8,7 +8,7 @@ // Resolver recurso/ação a partir da rota const routeAction = $derived.by(() => { const p = page.url.pathname; - if (p === '/' || p === '/solicitar-acesso') return null; + if (p === '/' || p === '/abrir-chamado') return null; // Funcionários if (p.startsWith('/recursos-humanos/funcionarios')) { diff --git a/apps/web/src/routes/(dashboard)/+page.svelte b/apps/web/src/routes/(dashboard)/+page.svelte index 0c7070b..2831e96 100644 --- a/apps/web/src/routes/(dashboard)/+page.svelte +++ b/apps/web/src/routes/(dashboard)/+page.svelte @@ -146,13 +146,13 @@

    {alertData.message}

    {#if alertType === "access_denied"}
    - + - Solicitar Acesso + Abrir Chamado diff --git a/apps/web/src/routes/(dashboard)/abrir-chamado/+page.svelte b/apps/web/src/routes/(dashboard)/abrir-chamado/+page.svelte new file mode 100644 index 0000000..c445f2e --- /dev/null +++ b/apps/web/src/routes/(dashboard)/abrir-chamado/+page.svelte @@ -0,0 +1,247 @@ + + +
    +
    +
    +
    + +
    +
    + + {#if feedback} +
    +
    + {feedback.mensagem} + {#if feedback.numero} +

    Número do ticket: {feedback.numero}

    + {/if} +
    +
    + {/if} + +
    +
    +
    +

    Formulário

    +

    + Informe os detalhes para que nossa equipe possa priorizar o atendimento. +

    +
    + {#if resetSignal % 2 === 0} + + {:else} + + {/if} +
    +
    +
    + + +
    +
    + diff --git a/apps/web/src/routes/(dashboard)/perfil/chamados/+page.svelte b/apps/web/src/routes/(dashboard)/perfil/chamados/+page.svelte new file mode 100644 index 0000000..3b4fcab --- /dev/null +++ b/apps/web/src/routes/(dashboard)/perfil/chamados/+page.svelte @@ -0,0 +1,321 @@ + + +
    +
    +
    +
    +

    Meu Perfil

    +

    Meus Chamados

    +

    + Acompanhe o status, interaja com a equipe de TI e visualize a timeline de SLA em tempo real. +

    +
    +
    + Abrir novo chamado + +
    +
    +
    + +
    + + +
    + {#if !selectedTicketId || !detalheAtual} +
    + {#if carregandoDetalhe} + + {:else} +

    Selecione um chamado para visualizar os detalhes.

    + {/if} +
    + {:else} +
    +
    +

    Ticket {detalheAtual.ticket.numero}

    +

    {detalheAtual.ticket.titulo}

    +

    {detalheAtual.ticket.descricao}

    +
    + + {getStatusLabel(detalheAtual.ticket.status)} + +
    + +
    + + Tipo: {detalheAtual.ticket.tipo.charAt(0).toUpperCase() + detalheAtual.ticket.tipo.slice(1)} + + + Prioridade: {detalheAtual.ticket.prioridade} + + + Última interação: {formatarData(detalheAtual.ticket.ultimaInteracaoEm)} + +
    + + {#if statusAlertas(detalheAtual.ticket).length > 0} +
    + {#each statusAlertas(detalheAtual.ticket) as alerta (alerta.label)} +
    + {alerta.label} +
    + {/each} +
    + {/if} + +
    +
    +

    Timeline e SLA

    +

    + Etapas monitoradas com indicadores de prazo. +

    +
    + +
    +
    + +
    +

    Responsabilidade

    +

    + {detalheAtual.ticket.responsavelId + ? `Responsável: ${detalheAtual.ticket.setorResponsavel ?? "Equipe TI"}` + : "Aguardando atribuição"} +

    +
    +

    Prazo resposta: {prazoRestante(detalheAtual.ticket.prazoResposta) ?? "--"}

    +

    Prazo conclusão: {prazoRestante(detalheAtual.ticket.prazoConclusao) ?? "--"}

    +

    Prazo encerramento: {prazoRestante(detalheAtual.ticket.prazoEncerramento) ?? "--"}

    +
    +

    + {getStatusDescription(detalheAtual.ticket.status)} +

    +
    +
    + +
    +
    +

    Interações

    +
    + {#if detalheAtual.interactions.length === 0} +

    + Nenhuma interação registrada ainda. +

    + {:else} + {#each detalheAtual.interactions as interacao (interacao._id)} +
    +
    + {interacao.origem === "usuario" ? "Você" : interacao.origem} + {formatarData(interacao.criadoEm)} +
    +

    + {interacao.conteudo} +

    + {#if interacao.statusNovo && interacao.statusNovo !== interacao.statusAnterior} + + Status: {getStatusLabel(interacao.statusNovo)} + + {/if} +
    + {/each} + {/if} +
    +
    + +
    +

    Enviar atualização

    + + {#if erroMensagem} +

    {erroMensagem}

    + {/if} + {#if sucessoMensagem} +

    {sucessoMensagem}

    + {/if} + +
    +
    + {/if} +
    +
    +
    + diff --git a/apps/web/src/routes/(dashboard)/solicitar-acesso/+page.svelte b/apps/web/src/routes/(dashboard)/solicitar-acesso/+page.svelte deleted file mode 100644 index 4acbe1a..0000000 --- a/apps/web/src/routes/(dashboard)/solicitar-acesso/+page.svelte +++ /dev/null @@ -1,348 +0,0 @@ - - -
    - -
    -
    -
    -
    - - Acesso ao Sistema - -

    - Solicitar Acesso ao SGSE -

    -

    - Preencha o formulário abaixo para solicitar acesso ao Sistema de Gerenciamento da Secretaria - de Esportes. Sua solicitação será analisada pela equipe de Tecnologia da Informação. -

    -
    -
    - - - {#if notice} -
    - - {#if notice.type === 'success'} - - {:else} - - {/if} - - {notice.message} -
    - {/if} - - -
    -
    -
    -
    { - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - > -
    - - - {#snippet children(field)} -
    - - field.handleChange(e.currentTarget.value)} - /> - {#if field.state.meta.errors.length > 0} - - {/if} -
    - {/snippet} -
    - - - - {#snippet children(field)} -
    - - field.handleChange(e.currentTarget.value)} - /> - {#if field.state.meta.errors.length > 0} - - {/if} -
    - {/snippet} -
    - - - - {#snippet children(field)} -
    - - field.handleChange(e.currentTarget.value)} - /> - {#if field.state.meta.errors.length > 0} - - {/if} -
    - {/snippet} -
    - - - - {#snippet children(field)} -
    - - { - const masked = maskTelefone(e.currentTarget.value); - e.currentTarget.value = masked; - field.handleChange(masked); - }} - maxlength="15" - /> - {#if field.state.meta.errors.length > 0} - - {/if} -
    - {/snippet} -
    -
    - - -
    - - -
    -
    -
    -
    - - -
    - - - -
    -

    Informações Importantes

    -
    -
    - - Todos os campos marcados com * são obrigatórios -
    -
    - - Sua solicitação será analisada pela equipe de TI em até 48 horas úteis -
    -
    - - Você receberá um e-mail com o resultado da análise -
    -
    - - Em caso de dúvidas, entre em contato com o suporte técnico -
    -
    -
    -
    -
    - - diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index 1ec6a7f..a3d7ef3 100644 --- a/apps/web/src/routes/(dashboard)/ti/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte @@ -198,6 +198,19 @@ palette: "primary", icon: "control", }, + { + title: "Central de Chamados", + description: + "Monitore tickets, configure SLA, atribua responsáveis e acompanhe alertas de prazos.", + ctaLabel: "Abrir Central", + href: "/ti/central-chamados", + palette: "info", + icon: "support", + highlightBadges: [ + { label: "SLA", variant: "solid" }, + { label: "Alertas", variant: "outline" }, + ], + }, { title: "Suporte Técnico", description: diff --git a/apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte b/apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte new file mode 100644 index 0000000..10dcc38 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte @@ -0,0 +1,388 @@ + + +
    +
    +
    +

    Total de chamados

    +

    {estatisticas.total}

    +
    +
    +

    Abertos

    +

    {estatisticas.abertos}

    +
    +
    +

    Em andamento

    +

    {estatisticas.emAndamento}

    +
    +
    +

    Vencidos/Cancelados

    +

    {estatisticas.vencidos}

    +
    +
    + +
    +
    +
    +

    Painel de chamados

    +

    + Filtros por status, responsável e setor. +

    +
    +
    + + + +
    +
    + +
    + + + + + + + + + + + + + {#if carregandoChamados} + + + + {:else if tickets.length === 0} + + + + {:else} + {#each tickets as ticket (ticket._id)} + selecionarTicket(ticket._id)} + > + + + + + + + + {/each} + {/if} + +
    TicketTipoStatusResponsávelPrioridadePrazo
    +
    + +
    +
    + Nenhum chamado encontrado. +
    +
    {ticket.numero}
    +
    {ticket.solicitanteNome}
    +
    {ticket.tipo} + {getStatusLabel(ticket.status)} + {ticket.setorResponsavel ?? "—"}{ticket.prioridade} + {ticket.prazoConclusao ? prazoRestante(ticket.prazoConclusao) : "--"} +
    +
    +
    + +
    +
    +

    Detalhes do chamado

    + {#if !detalheSelecionado} +

    Selecione um chamado na tabela.

    + {:else} +
    +
    +
    +

    Solicitante

    +

    {detalheSelecionado.solicitanteNome}

    +
    + + {getStatusLabel(detalheSelecionado.status)} + +
    +

    {detalheSelecionado.descricao}

    +
    +
    +

    Prazo resposta

    +

    {prazoRestante(detalheSelecionado.prazoResposta) ?? "--"}

    +
    +
    +

    Prazo conclusão

    +

    {prazoRestante(detalheSelecionado.prazoConclusao) ?? "--"}

    +
    +
    +
    + +
    +
    + {/if} +
    + +
    +

    Atribuir responsável

    +
    + + + {#if assignFeedback} +

    {assignFeedback}

    + {/if} + +
    +
    +
    + +
    +
    +
    +

    Configuração de SLA

    +

    Defina tempos de resposta, conclusão e alertas.

    +
    +
    + {#if slaConfigsQuery?.data} + {#each slaConfigsQuery.data as sla (sla._id)} + + {/each} + {/if} +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + {#if slaFeedback} +

    {slaFeedback}

    + {/if} + +
    +
    + diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 87b4b87..b863aa0 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -17,6 +17,7 @@ import type * as atestadosLicencas from "../atestadosLicencas.js"; import type * as ausencias from "../ausencias.js"; import type * as auth from "../auth.js"; import type * as auth_utils from "../auth/utils.js"; +import type * as chamados from "../chamados.js"; import type * as chat from "../chat.js"; import type * as configuracaoEmail from "../configuracaoEmail.js"; import type * as crons from "../crons.js"; @@ -64,6 +65,7 @@ declare const fullApi: ApiFromModules<{ ausencias: typeof ausencias; auth: typeof auth; "auth/utils": typeof auth_utils; + chamados: typeof chamados; chat: typeof chat; configuracaoEmail: typeof configuracaoEmail; crons: typeof crons; diff --git a/packages/backend/convex/chamados.ts b/packages/backend/convex/chamados.ts new file mode 100644 index 0000000..0d2a3cf --- /dev/null +++ b/packages/backend/convex/chamados.ts @@ -0,0 +1,585 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import type { MutationCtx } from "./_generated/server"; +import { api } from "./_generated/api"; +import { getCurrentUserFunction } from "./auth"; +import type { Doc, Id } from "./_generated/dataModel"; + +const ticketStatusValidator = v.union( + v.literal("aberto"), + v.literal("em_andamento"), + v.literal("aguardando_usuario"), + v.literal("resolvido"), + v.literal("encerrado"), + v.literal("cancelado") +); + +const ticketTipoValidator = v.union( + v.literal("reclamacao"), + v.literal("elogio"), + v.literal("sugestao"), + v.literal("chamado") +); + +const prioridadeValidator = v.union( + v.literal("baixa"), + v.literal("media"), + v.literal("alta"), + v.literal("critica") +); + +const arquivoValidator = v.object({ + arquivoId: v.id("_storage"), + nome: v.optional(v.string()), + tipo: v.optional(v.string()), + tamanho: v.optional(v.number()), +}); + +async function assertAuth(ctx: Parameters[0]) { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error("Usuário não autenticado"); + } + return usuario; +} + +type TicketDoc = Doc<"tickets">; +type SlaDoc = Doc<"slaConfigs"> | null; + +function gerarNumeroTicket(): string { + const agora = new Date(); + const ano = agora.getFullYear(); + const mes = String(agora.getMonth() + 1).padStart(2, "0"); + const sequencia = Math.floor(Math.random() * 9000 + 1000); + return `SGSE-${ano}${mes}-${sequencia}`; +} + +function calcularPrazos(base: number, sla: SlaDoc) { + const horaMs = 60 * 60 * 1000; + + const tempoResposta = sla?.tempoRespostaHoras ?? 4; + const tempoConclusao = sla?.tempoConclusaoHoras ?? 24; + const tempoEncerramento = sla?.tempoEncerramentoHoras ?? null; + + return { + resposta: base + tempoResposta * horaMs, + conclusao: base + tempoConclusao * horaMs, + encerramento: tempoEncerramento ? base + tempoEncerramento * horaMs : null, + }; +} + +function montarTimeline(base: number, prazos: ReturnType) { + const timeline: NonNullable = [ + { + etapa: "abertura", + status: "concluido", + prazo: base, + concluidoEm: base, + observacao: "Chamado registrado com sucesso", + }, + { + etapa: "resposta_inicial", + status: "pendente", + prazo: prazos.resposta, + }, + { + etapa: "conclusao", + status: "pendente", + prazo: prazos.conclusao, + }, + ]; + + if (prazos.encerramento) { + timeline.push({ + etapa: "encerramento", + status: "pendente", + prazo: prazos.encerramento, + }); + } + + return timeline; +} + +async function selecionarSlaConfig(ctx: Parameters[0], slaConfigId?: Id<"slaConfigs">) { + if (slaConfigId) { + return await ctx.db.get(slaConfigId); + } + + return await ctx.db + .query("slaConfigs") + .withIndex("by_ativo", (q) => q.eq("ativo", true)) + .first(); +} + +async function registrarNotificacoes( + ctx: MutationCtx, + params: { + ticket: Doc<"tickets">; + titulo: string; + mensagem: string; + usuarioEvento: Id<"usuarios">; + } +) { + const { ticket, titulo, mensagem, usuarioEvento } = params; + + if (ticket.solicitanteEmail) { + await ctx.runMutation(api.email.enfileirarEmail, { + destinatario: ticket.solicitanteEmail, + destinatarioId: ticket.solicitanteId, + assunto: `${titulo} - Chamado ${ticket.numero}`, + corpo: `${mensagem}\n\n---\nCentral de Chamados SGSE`, + enviadoPor: usuarioEvento, + }); + } + + await ctx.db.insert("notificacoes", { + usuarioId: ticket.solicitanteId, + tipo: "nova_mensagem", + ...(ticket.conversaId ? { conversaId: ticket.conversaId } : {}), + remetenteId: usuarioEvento, + titulo, + descricao: mensagem.length > 120 ? `${mensagem.slice(0, 117)}...` : mensagem, + lida: false, + criadaEm: Date.now(), + }); +} + +async function registrarInteracao( + ctx: MutationCtx, + params: { + ticketId: Id<"tickets">; + autorId: Id<"usuarios"> | null; + origem: "usuario" | "ti" | "sistema"; + tipo: "mensagem" | "status" | "anexo" | "alerta"; + conteudo: string; + visibilidade?: "publico" | "interno"; + anexos?: Array<{ + arquivoId: Id<"_storage">; + nome?: string; + tipo?: string; + tamanho?: number; + }>; + statusAnterior?: TicketDoc["status"]; + statusNovo?: TicketDoc["status"]; + } +) { + return await ctx.db.insert("ticketInteractions", { + ticketId: params.ticketId, + autorId: params.autorId || undefined, + origem: params.origem, + tipo: params.tipo, + conteudo: params.conteudo, + visibilidade: params.visibilidade ?? "publico", + anexos: params.anexos, + statusAnterior: params.statusAnterior, + statusNovo: params.statusNovo, + criadoEm: Date.now(), + }); +} + +export const abrirChamado = mutation({ + args: { + titulo: v.string(), + descricao: v.string(), + tipo: ticketTipoValidator, + categoria: v.optional(v.string()), + prioridade: prioridadeValidator, + anexos: v.optional(v.array(arquivoValidator)), + slaConfigId: v.optional(v.id("slaConfigs")), + canalOrigem: v.optional(v.string()), + }, + returns: v.object({ + ticketId: v.id("tickets"), + numero: v.string(), + }), + handler: async (ctx, args) => { + const usuario = await assertAuth(ctx); + const agora = Date.now(); + const sla = await selecionarSlaConfig(ctx, args.slaConfigId); + const prazos = calcularPrazos(agora, sla); + const timeline = montarTimeline(agora, prazos); + + const ticketId = await ctx.db.insert("tickets", { + numero: gerarNumeroTicket(), + titulo: args.titulo.trim(), + descricao: args.descricao.trim(), + tipo: args.tipo, + categoria: args.categoria, + status: "aberto", + prioridade: args.prioridade, + solicitanteId: usuario._id, + solicitanteNome: usuario.nome, + solicitanteEmail: usuario.email, + responsavelId: undefined, + setorResponsavel: undefined, + slaConfigId: sla?._id, + conversaId: undefined, + prazoResposta: prazos.resposta, + prazoConclusao: prazos.conclusao, + prazoEncerramento: prazos.encerramento ?? undefined, + timeline, + alertasEmitidos: [], + anexos: args.anexos, + tags: undefined, + canalOrigem: args.canalOrigem, + ultimaInteracaoEm: agora, + criadoEm: agora, + atualizadoEm: agora, + }); + + await registrarInteracao(ctx, { + ticketId, + autorId: usuario._id, + origem: "usuario", + tipo: "mensagem", + conteudo: args.descricao, + anexos: args.anexos, + }); + + const ticket = await ctx.db.get(ticketId); + if (ticket) { + await registrarNotificacoes(ctx, { + ticket, + titulo: "Chamado registrado", + mensagem: "Recebemos sua solicitação e iniciaremos o atendimento em breve.", + usuarioEvento: usuario._id, + }); + } + + return { + ticketId, + numero: ticket ? ticket.numero : "", + }; + }, +}); + +export const listarChamadosUsuario = query({ + args: {}, + handler: async (ctx) => { + const usuario = await assertAuth(ctx); + + const tickets = await ctx.db + .query("tickets") + .withIndex("by_solicitante", (q) => q.eq("solicitanteId", usuario._id)) + .collect(); + + tickets.sort((a, b) => b.criadoEm - a.criadoEm); + return tickets; + }, +}); + +export const listarChamadosTI = query({ + args: { + status: v.optional(ticketStatusValidator), + responsavelId: v.optional(v.id("usuarios")), + setor: v.optional(v.string()), + limite: v.optional(v.number()), + }, + handler: async (ctx, args) => { + // Permitir apenas usuários autenticados (regras detalhadas devem ser aplicadas no frontend) + await assertAuth(ctx); + + let tickets: Array> = []; + + if (args.responsavelId) { + tickets = await ctx.db + .query("tickets") + .withIndex("by_responsavel", (q) => + q.eq("responsavelId", args.responsavelId).eq("status", args.status ?? "aberto") + ) + .collect(); + } else if (args.status) { + tickets = await ctx.db + .query("tickets") + .withIndex("by_status", (q) => q.eq("status", args.status!)) + .collect(); + } else { + tickets = await ctx.db.query("tickets").collect(); + } + + const filtrados = tickets.filter((ticket) => { + if (args.setor && ticket.setorResponsavel !== args.setor) { + return false; + } + return true; + }); + + filtrados.sort((a, b) => b.atualizadoEm - a.atualizadoEm); + return args.limite ? filtrados.slice(0, args.limite) : filtrados; + }, +}); + +export const obterChamado = query({ + args: { + ticketId: v.id("tickets"), + }, + handler: async (ctx, args) => { + const usuario = await assertAuth(ctx); + const ticket = await ctx.db.get(args.ticketId); + if (!ticket) { + throw new Error("Chamado não encontrado"); + } + + const podeVer = + ticket.solicitanteId === usuario._id || + ticket.responsavelId === usuario._id || + ticket.setorResponsavel === usuario.setor; + + if (!podeVer) { + throw new Error("Acesso negado ao chamado"); + } + + const interactions = await ctx.db + .query("ticketInteractions") + .withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId)) + .collect(); + + interactions.sort((a, b) => a.criadoEm - b.criadoEm); + + return { + ticket, + interactions, + }; + }, +}); + +export const registrarAtualizacao = mutation({ + args: { + ticketId: v.id("tickets"), + conteudo: v.string(), + anexos: v.optional(v.array(arquivoValidator)), + visibilidade: v.optional(v.union(v.literal("publico"), v.literal("interno"))), + proximoStatus: v.optional(ticketStatusValidator), + }, + handler: async (ctx, args) => { + const usuario = await assertAuth(ctx); + const ticket = await ctx.db.get(args.ticketId); + if (!ticket) { + throw new Error("Chamado não encontrado"); + } + + const agora = Date.now(); + let novoStatus = ticket.status; + + if (args.proximoStatus && args.proximoStatus !== ticket.status) { + novoStatus = args.proximoStatus; + await ctx.db.patch(ticket._id, { + status: novoStatus, + atualizadoEm: agora, + ultimaInteracaoEm: agora, + }); + } else { + await ctx.db.patch(ticket._id, { + atualizadoEm: agora, + ultimaInteracaoEm: agora, + }); + } + + await registrarInteracao(ctx, { + ticketId: ticket._id, + autorId: usuario._id, + origem: "ti", + tipo: args.proximoStatus ? "status" : "mensagem", + conteudo: args.conteudo, + visibilidade: args.visibilidade, + anexos: args.anexos, + statusAnterior: args.proximoStatus ? ticket.status : undefined, + statusNovo: args.proximoStatus ? novoStatus : undefined, + }); + + const ticketAtualizado = await ctx.db.get(ticket._id); + if (ticketAtualizado) { + await registrarNotificacoes(ctx, { + ticket: ticketAtualizado, + titulo: `Atualização no chamado ${ticketAtualizado.numero}`, + mensagem: args.conteudo, + usuarioEvento: usuario._id, + }); + } + + return { status: novoStatus }; + }, +}); + +export const atribuirResponsavel = mutation({ + args: { + ticketId: v.id("tickets"), + responsavelId: v.id("usuarios"), + motivo: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const usuario = await assertAuth(ctx); + const ticket = await ctx.db.get(args.ticketId); + if (!ticket) { + throw new Error("Chamado não encontrado"); + } + + const responsavel = await ctx.db.get(args.responsavelId); + if (!responsavel) { + throw new Error("Responsável inválido"); + } + + const agora = Date.now(); + + await ctx.db.patch(ticket._id, { + responsavelId: args.responsavelId, + setorResponsavel: responsavel.setor, + atualizadoEm: agora, + }); + + const assignmentsAtivos = await ctx.db + .query("ticketAssignments") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id).eq("ativo", true)) + .collect(); + + for (const assignment of assignmentsAtivos) { + await ctx.db.patch(assignment._id, { + ativo: false, + encerradoEm: agora, + }); + } + + await ctx.db.insert("ticketAssignments", { + ticketId: ticket._id, + responsavelId: args.responsavelId, + atribuidoPor: usuario._id, + motivo: args.motivo, + ativo: true, + criadoEm: agora, + encerradoEm: undefined, + }); + + await registrarInteracao(ctx, { + ticketId: ticket._id, + autorId: usuario._id, + origem: "ti", + tipo: "status", + conteudo: `Chamado atribuído para ${responsavel.nome}`, + }); + + const ticketAtualizado = await ctx.db.get(ticket._id); + if (ticketAtualizado) { + await registrarNotificacoes(ctx, { + ticket: ticketAtualizado, + titulo: "Chamado atribuído", + mensagem: `Seu chamado agora está com ${responsavel.nome}.`, + usuarioEvento: usuario._id, + }); + } + + return { responsavelId: args.responsavelId }; + }, +}); + +export const listarSlaConfigs = query({ + args: {}, + handler: async (ctx) => { + await assertAuth(ctx); + const slaConfigs = await ctx.db.query("slaConfigs").collect(); + slaConfigs.sort((a, b) => b.criadoEm - a.criadoEm); + return slaConfigs; + }, +}); + +export const salvarSlaConfig = mutation({ + args: { + slaId: v.optional(v.id("slaConfigs")), + nome: v.string(), + descricao: v.optional(v.string()), + setores: v.optional(v.array(v.string())), + tempoRespostaHoras: v.number(), + tempoConclusaoHoras: v.number(), + tempoEncerramentoHoras: v.optional(v.number()), + alertaAntecedenciaHoras: v.number(), + ativo: v.boolean(), + }, + handler: async (ctx, args) => { + const usuario = await assertAuth(ctx); + const agora = Date.now(); + + if (args.slaId) { + await ctx.db.patch(args.slaId, { + nome: args.nome, + descricao: args.descricao, + setores: args.setores, + tempoRespostaHoras: args.tempoRespostaHoras, + tempoConclusaoHoras: args.tempoConclusaoHoras, + tempoEncerramentoHoras: args.tempoEncerramentoHoras, + alertaAntecedenciaHoras: args.alertaAntecedenciaHoras, + ativo: args.ativo, + atualizadoPor: usuario._id, + atualizadoEm: agora, + }); + return args.slaId; + } + + return await ctx.db.insert("slaConfigs", { + nome: args.nome, + descricao: args.descricao, + setores: args.setores, + tempoRespostaHoras: args.tempoRespostaHoras, + tempoConclusaoHoras: args.tempoConclusaoHoras, + tempoEncerramentoHoras: args.tempoEncerramentoHoras, + alertaAntecedenciaHoras: args.alertaAntecedenciaHoras, + ativo: args.ativo, + criadoPor: usuario._id, + atualizadoPor: usuario._id, + criadoEm: agora, + atualizadoEm: agora, + }); + }, +}); + +export const emitirAlertaPrazo = mutation({ + args: { + ticketId: v.id("tickets"), + tipo: v.union( + v.literal("resposta"), + v.literal("conclusao"), + v.literal("encerramento") + ), + mensagem: v.string(), + }, + handler: async (ctx, args) => { + const usuario = await assertAuth(ctx); + const ticket = await ctx.db.get(args.ticketId); + if (!ticket) { + throw new Error("Chamado não encontrado"); + } + + const atualizado = [ + ...(ticket.alertasEmitidos || []), + { tipo: args.tipo, emitidoEm: Date.now() }, + ]; + + await ctx.db.patch(ticket._id, { + alertasEmitidos: atualizado, + }); + + await registrarInteracao(ctx, { + ticketId: ticket._id, + autorId: usuario._id, + origem: "sistema", + tipo: "alerta", + conteudo: args.mensagem, + }); + + await registrarNotificacoes(ctx, { + ticket, + titulo: `Alerta de SLA (${args.tipo})`, + mensagem: args.mensagem, + usuarioEvento: usuario._id, + }); + + return { sucesso: true }; + }, +}); + +export const generateUploadUrl = mutation({ + args: {}, + handler: async (ctx) => { + await assertAuth(ctx); + return await ctx.storage.generateUploadUrl(); + }, +}); + diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index e7be7e9..2b5291f 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -769,4 +769,173 @@ export default defineSchema({ .index("by_timestamp", ["timestamp"]) .index("by_status", ["status"]) .index("by_config", ["configId", "timestamp"]), + + tickets: defineTable({ + numero: v.string(), + titulo: v.string(), + descricao: v.string(), + tipo: v.union( + v.literal("reclamacao"), + v.literal("elogio"), + v.literal("sugestao"), + v.literal("chamado") + ), + categoria: v.optional(v.string()), + status: v.union( + v.literal("aberto"), + v.literal("em_andamento"), + v.literal("aguardando_usuario"), + v.literal("resolvido"), + v.literal("encerrado"), + v.literal("cancelado") + ), + prioridade: v.union( + v.literal("baixa"), + v.literal("media"), + v.literal("alta"), + v.literal("critica") + ), + solicitanteId: v.id("usuarios"), + solicitanteNome: v.string(), + solicitanteEmail: v.string(), + responsavelId: v.optional(v.id("usuarios")), + setorResponsavel: v.optional(v.string()), + slaConfigId: v.optional(v.id("slaConfigs")), + conversaId: v.optional(v.id("conversas")), + prazoResposta: v.optional(v.number()), + prazoConclusao: v.optional(v.number()), + prazoEncerramento: v.optional(v.number()), + timeline: v.optional( + v.array( + v.object({ + etapa: v.string(), + status: v.union( + v.literal("pendente"), + v.literal("em_andamento"), + v.literal("concluido"), + v.literal("vencido") + ), + prazo: v.optional(v.number()), + concluidoEm: v.optional(v.number()), + observacao: v.optional(v.string()), + }) + ) + ), + alertasEmitidos: v.optional( + v.array( + v.object({ + tipo: v.union( + v.literal("resposta"), + v.literal("conclusao"), + v.literal("encerramento") + ), + emitidoEm: v.number(), + }) + ) + ), + anexos: v.optional( + v.array( + v.object({ + arquivoId: v.id("_storage"), + nome: v.optional(v.string()), + tipo: v.optional(v.string()), + tamanho: v.optional(v.number()), + }) + ) + ), + tags: v.optional(v.array(v.string())), + canalOrigem: v.optional(v.string()), + ultimaInteracaoEm: v.number(), + criadoEm: v.number(), + atualizadoEm: v.number(), + }) + .index("by_numero", ["numero"]) + .index("by_status", ["status"]) + .index("by_solicitante", ["solicitanteId", "status"]) + .index("by_responsavel", ["responsavelId", "status"]) + .index("by_setor", ["setorResponsavel", "status"]), + + ticketInteractions: defineTable({ + ticketId: v.id("tickets"), + autorId: v.optional(v.id("usuarios")), + origem: v.union( + v.literal("usuario"), + v.literal("ti"), + v.literal("sistema") + ), + tipo: v.union( + v.literal("mensagem"), + v.literal("status"), + v.literal("anexo"), + v.literal("alerta") + ), + conteudo: v.string(), + anexos: v.optional( + v.array( + v.object({ + arquivoId: v.id("_storage"), + nome: v.optional(v.string()), + tipo: v.optional(v.string()), + tamanho: v.optional(v.number()), + }) + ) + ), + statusAnterior: v.optional( + v.union( + v.literal("aberto"), + v.literal("em_andamento"), + v.literal("aguardando_usuario"), + v.literal("resolvido"), + v.literal("encerrado"), + v.literal("cancelado") + ) + ), + statusNovo: v.optional( + v.union( + v.literal("aberto"), + v.literal("em_andamento"), + v.literal("aguardando_usuario"), + v.literal("resolvido"), + v.literal("encerrado"), + v.literal("cancelado") + ) + ), + visibilidade: v.union( + v.literal("publico"), + v.literal("interno") + ), + criadoEm: v.number(), + }) + .index("by_ticket", ["ticketId"]) + .index("by_ticket_type", ["ticketId", "tipo"]) + .index("by_autor", ["autorId"]), + + slaConfigs: defineTable({ + nome: v.string(), + descricao: v.optional(v.string()), + setores: v.optional(v.array(v.string())), + tempoRespostaHoras: v.number(), + tempoConclusaoHoras: v.number(), + tempoEncerramentoHoras: v.optional(v.number()), + alertaAntecedenciaHoras: v.number(), + ativo: v.boolean(), + criadoPor: v.id("usuarios"), + atualizadoPor: v.optional(v.id("usuarios")), + criadoEm: v.number(), + atualizadoEm: v.number(), + }) + .index("by_ativo", ["ativo"]) + .index("by_nome", ["nome"]), + + ticketAssignments: defineTable({ + ticketId: v.id("tickets"), + responsavelId: v.id("usuarios"), + atribuidoPor: v.id("usuarios"), + motivo: v.optional(v.string()), + ativo: v.boolean(), + criadoEm: v.number(), + encerradoEm: v.optional(v.number()), + }) + .index("by_ticket", ["ticketId", "ativo"]) + .index("by_responsavel", ["responsavelId", "ativo"]), });