Feat cibersecurity #27
@@ -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;
|
||||
|
||||
@@ -376,11 +376,11 @@
|
||||
{/each}
|
||||
<li class="mt-auto rounded-xl">
|
||||
<a
|
||||
href={resolve('/solicitar-acesso')}
|
||||
class={getSolicitarClasses(currentPath === '/solicitar-acesso')}
|
||||
href={resolve('/abrir-chamado')}
|
||||
class={getSolicitarClasses(currentPath === '/abrir-chamado')}
|
||||
>
|
||||
<UserPlus class="h-5 w-5" strokeWidth={2} />
|
||||
<span>Solicitar acesso</span>
|
||||
<span>Abrir Chamado</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -460,11 +460,11 @@
|
||||
</div>
|
||||
<div class="mt-4 space-y-2 text-center">
|
||||
<a
|
||||
href={resolve('/solicitar-acesso')}
|
||||
href={resolve('/abrir-chamado')}
|
||||
class="link link-primary block text-sm"
|
||||
onclick={closeLoginModal}
|
||||
>
|
||||
Não tem acesso? Solicite aqui
|
||||
Abrir Chamado
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/esqueci-senha')}
|
||||
|
||||
107
apps/web/src/lib/components/chamados/TicketCard.svelte
Normal file
107
apps/web/src/lib/components/chamados/TicketCard.svelte
Normal file
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
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<Props>();
|
||||
const ticket = $derived(props.ticket);
|
||||
const selected = $derived(props.selected ?? false);
|
||||
|
||||
const prioridadeClasses: Record<string, string> = {
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<article
|
||||
class={`rounded-2xl border p-4 transition-all duration-200 ${
|
||||
selected
|
||||
? "border-primary bg-primary/5 shadow-lg"
|
||||
: "border-base-200 bg-base-100/70 hover:border-primary/40 hover:shadow-md"
|
||||
}`}
|
||||
>
|
||||
<button class="w-full text-left" type="button" onclick={handleSelect}>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-base-content/50">
|
||||
Ticket {ticket.numero}
|
||||
</p>
|
||||
<h3 class="text-lg font-semibold text-base-content">{ticket.titulo}</h3>
|
||||
</div>
|
||||
<span class={getStatusBadge(ticket.status)}>{getStatusLabel(ticket.status)}</span>
|
||||
</div>
|
||||
|
||||
<p class="text-base-content/60 mt-2 text-sm line-clamp-2">{ticket.descricao}</p>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-base-content/60">
|
||||
<span class={prioridadeClasses[ticket.prioridade] ?? "badge badge-sm"}>
|
||||
Prioridade {ticket.prioridade}
|
||||
</span>
|
||||
<span class="badge badge-xs badge-outline">
|
||||
{ticket.tipo.charAt(0).toUpperCase() + ticket.tipo.slice(1)}
|
||||
</span>
|
||||
{#if ticket.setorResponsavel}
|
||||
<span class="badge badge-xs badge-outline badge-ghost">
|
||||
{ticket.setorResponsavel}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-1 text-xs text-base-content/50">
|
||||
<p>
|
||||
Última interação: {formatarData(ticket.ultimaInteracaoEm)}
|
||||
</p>
|
||||
<p>{getStatusDescription(ticket.status)}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each getPrazoBadges() as badge (badge.label)}
|
||||
<span class={badge.classe}>{badge.label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
|
||||
249
apps/web/src/lib/components/chamados/TicketForm.svelte
Normal file
249
apps/web/src/lib/components/chamados/TicketForm.svelte
Normal file
@@ -0,0 +1,249 @@
|
||||
<script lang="ts">
|
||||
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
type SlaConfig = Doc<"slaConfigs">;
|
||||
|
||||
interface FormValues {
|
||||
titulo: string;
|
||||
descricao: string;
|
||||
tipo: Doc<"tickets">["tipo"];
|
||||
prioridade: Doc<"tickets">["prioridade"];
|
||||
categoria: string;
|
||||
slaConfigId?: Id<"slaConfigs">;
|
||||
canalOrigem?: string;
|
||||
anexos: File[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
slaConfigs?: Array<SlaConfig>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{ submit: { values: FormValues } }>();
|
||||
const props = $props<Props>();
|
||||
const slaConfigs = $derived<Array<SlaConfig>>(props.slaConfigs ?? []);
|
||||
const loading = $derived(props.loading ?? false);
|
||||
|
||||
let titulo = $state("");
|
||||
let descricao = $state("");
|
||||
let tipo = $state<Doc<"tickets">["tipo"]>("chamado");
|
||||
let prioridade = $state<Doc<"tickets">["prioridade"]>("media");
|
||||
let categoria = $state("");
|
||||
let slaConfigId = $state<Id<"slaConfigs"> | "">("");
|
||||
let canalOrigem = $state("Portal SGSE");
|
||||
let anexos = $state<Array<File>>([]);
|
||||
let errors = $state<Record<string, string>>({});
|
||||
function validate(): boolean {
|
||||
const novoErros: Record<string, string> = {};
|
||||
if (!titulo.trim()) novoErros.titulo = "Informe um título para o chamado.";
|
||||
if (!descricao.trim()) novoErros.descricao = "Descrição é obrigatória.";
|
||||
if (!categoria.trim()) novoErros.categoria = "Informe uma categoria.";
|
||||
errors = novoErros;
|
||||
return Object.keys(novoErros).length === 0;
|
||||
}
|
||||
|
||||
function handleFiles(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const files = Array.from(target.files ?? []);
|
||||
anexos = files.slice(0, 5); // limitar para 5 anexos
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
anexos = anexos.filter((_, idx) => idx !== index);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
titulo = "";
|
||||
descricao = "";
|
||||
categoria = "";
|
||||
tipo = "chamado";
|
||||
prioridade = "media";
|
||||
anexos = [];
|
||||
errors = {};
|
||||
}
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
const slaSelecionada =
|
||||
(slaConfigId && slaConfigId !== "" ? (slaConfigId as Id<"slaConfigs">) : slaConfigs[0]?._id) ??
|
||||
undefined;
|
||||
|
||||
dispatch("submit", {
|
||||
values: {
|
||||
titulo: titulo.trim(),
|
||||
descricao: descricao.trim(),
|
||||
tipo,
|
||||
prioridade,
|
||||
categoria: categoria.trim(),
|
||||
slaConfigId: slaSelecionada,
|
||||
canalOrigem,
|
||||
anexos,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="space-y-8" onsubmit={handleSubmit}>
|
||||
<section class="grid gap-6 md:grid-cols-2">
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Título do chamado</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered input-primary w-full"
|
||||
placeholder="Ex: Erro ao acessar o módulo de licitações"
|
||||
bind:value={titulo}
|
||||
/>
|
||||
{#if errors.titulo}
|
||||
<span class="text-error mt-1 text-sm">{errors.titulo}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Tipo de solicitação</span>
|
||||
</label>
|
||||
<div class="grid gap-2">
|
||||
{#each ["chamado", "reclamacao", "elogio", "sugestao"] as opcao}
|
||||
<label class="btn btn-outline btn-sm justify-start gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="tipo"
|
||||
class="radio radio-primary"
|
||||
value={opcao}
|
||||
checked={tipo === opcao}
|
||||
onclick={() => (tipo = opcao as typeof tipo)}
|
||||
/>
|
||||
{opcao.charAt(0).toUpperCase() + opcao.slice(1)}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Prioridade</span>
|
||||
</label>
|
||||
<select class="select select-bordered w-full" bind:value={prioridade}>
|
||||
<option value="baixa">Baixa</option>
|
||||
<option value="media">Média</option>
|
||||
<option value="alta">Alta</option>
|
||||
<option value="critica">Crítica</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Categoria</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Ex: Infraestrutura, Sistemas, Acesso"
|
||||
bind:value={categoria}
|
||||
/>
|
||||
{#if errors.categoria}
|
||||
<span class="text-error mt-1 text-sm">{errors.categoria}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Configuração de SLA</span>
|
||||
</label>
|
||||
<select class="select select-bordered w-full" bind:value={slaConfigId}>
|
||||
{#each slaConfigs as sla (sla._id)}
|
||||
<option value={sla._id}>
|
||||
{sla.nome} • Resp. {sla.tempoRespostaHoras}h • Conc.
|
||||
{sla.tempoConclusaoHoras}h
|
||||
</option>
|
||||
{:else}
|
||||
<option value="">Padrão (24h)</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Descrição detalhada</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="textarea textarea-bordered textarea-lg min-h-[180px]"
|
||||
placeholder="Descreva o problema, erro ou sugestão com o máximo de detalhes possível."
|
||||
bind:value={descricao}
|
||||
></textarea>
|
||||
{#if errors.descricao}
|
||||
<span class="text-error mt-1 text-sm">{errors.descricao}</span>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-base-content">Anexos (opcional)</p>
|
||||
<p class="text-base-content/60 text-sm">
|
||||
Suporte a PDF e imagens (máx. 10MB por arquivo)
|
||||
</p>
|
||||
</div>
|
||||
<label class="btn btn-outline btn-sm">
|
||||
Selecionar arquivos
|
||||
<input type="file" class="hidden" multiple accept=".pdf,.png,.jpg,.jpeg" onchange={handleFiles} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if anexos.length > 0}
|
||||
<div class="space-y-2 rounded-2xl border border-base-200 bg-base-100/70 p-4">
|
||||
{#each anexos as file, index (file.name + index)}
|
||||
<div class="flex items-center justify-between gap-3 rounded-xl border border-base-200 bg-base-100 px-3 py-2">
|
||||
<div>
|
||||
<p class="text-sm font-medium">{file.name}</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{(file.size / 1024 / 1024).toFixed(2)} MB • {file.type}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
onclick={() => removeFile(index)}
|
||||
>
|
||||
Remover
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-2xl border border-dashed border-base-300 bg-base-100/50 p-6 text-center text-sm text-base-content/60">
|
||||
Nenhum arquivo selecionado.
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary flex-1 min-w-[200px]"
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Enviando...
|
||||
{:else}
|
||||
Registrar chamado
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={resetForm}
|
||||
disabled={loading}
|
||||
>
|
||||
Limpar
|
||||
</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
86
apps/web/src/lib/components/chamados/TicketTimeline.svelte
Normal file
86
apps/web/src/lib/components/chamados/TicketTimeline.svelte
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import type { Doc } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import {
|
||||
formatarData,
|
||||
formatarTimelineEtapa,
|
||||
prazoRestante,
|
||||
timelineStatus,
|
||||
} from "$lib/utils/chamados";
|
||||
|
||||
type Ticket = Doc<"tickets">;
|
||||
type TimelineEntry = NonNullable<Ticket["timeline"]>[number];
|
||||
|
||||
interface Props {
|
||||
timeline?: Array<TimelineEntry>;
|
||||
}
|
||||
|
||||
const props = $props<Props>();
|
||||
const timeline = $derived<Array<TimelineEntry>>(props.timeline ?? []);
|
||||
|
||||
const badgeClasses: Record<string, string> = {
|
||||
success: "bg-success/20 text-success border-success/40",
|
||||
warning: "bg-warning/20 text-warning border-warning/40",
|
||||
error: "bg-error/20 text-error border-error/40",
|
||||
info: "bg-info/20 text-info border-info/40",
|
||||
};
|
||||
|
||||
function getBadgeClass(entry: TimelineEntry) {
|
||||
const status = timelineStatus(entry);
|
||||
return badgeClasses[status] ?? badgeClasses.info;
|
||||
}
|
||||
|
||||
function getStatusLabel(entry: TimelineEntry) {
|
||||
if (entry.status === "concluido") return "Concluído";
|
||||
if (entry.status === "em_andamento") return "Em andamento";
|
||||
if (entry.status === "vencido") return "Vencido";
|
||||
return "Pendente";
|
||||
}
|
||||
|
||||
function getPrazoDescricao(entry: TimelineEntry) {
|
||||
if (entry.status === "concluido" && entry.concluidoEm) {
|
||||
return `Concluído em ${formatarData(entry.concluidoEm)}`;
|
||||
}
|
||||
if (!entry.prazo) return "Sem prazo definido";
|
||||
return `${formatarData(entry.prazo)} • ${prazoRestante(entry.prazo) ?? ""}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if timeline.length === 0}
|
||||
<div class="alert alert-info">
|
||||
<span>Nenhuma etapa registrada ainda.</span>
|
||||
</div>
|
||||
{:else}
|
||||
{#each timeline as entry (entry.etapa + entry.prazo)}
|
||||
<div class="flex gap-3">
|
||||
<div class="relative flex flex-col items-center">
|
||||
<div class={`badge border ${getBadgeClass(entry)}`}>
|
||||
{formatarTimelineEtapa(entry.etapa)}
|
||||
</div>
|
||||
{#if entry !== timeline[timeline.length - 1]}
|
||||
<div class="bg-base-200/80 mt-2 h-full w-px flex-1"></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 rounded-2xl border border-base-200 bg-base-100/80 p-4 shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-semibold text-base-content">
|
||||
{getStatusLabel(entry)}
|
||||
</span>
|
||||
{#if entry.status !== "concluido" && entry.prazo}
|
||||
<span class="badge badge-sm badge-outline">
|
||||
{prazoRestante(entry.prazo)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if entry.observacao}
|
||||
<p class="text-base-content/70 mt-2 text-sm">{entry.observacao}</p>
|
||||
{/if}
|
||||
<p class="text-base-content/50 mt-3 text-xs uppercase tracking-wide">
|
||||
{getPrazoDescricao(entry)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
53
apps/web/src/lib/stores/chamados.ts
Normal file
53
apps/web/src/lib/stores/chamados.ts
Normal file
@@ -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<Array<Doc<"tickets">>>([]);
|
||||
const detalhes = writable<Record<string, TicketDetalhe>>({});
|
||||
const carregando = writable(false);
|
||||
|
||||
function setTickets(lista: Array<Doc<"tickets">>) {
|
||||
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();
|
||||
|
||||
123
apps/web/src/lib/utils/chamados.ts
Normal file
123
apps/web/src/lib/utils/chamados.ts
Normal file
@@ -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<Ticket["timeline"]>[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<string, string> = {
|
||||
abertura: "Registro",
|
||||
resposta_inicial: "Resposta inicial",
|
||||
conclusao: "Conclusão",
|
||||
encerramento: "Encerramento",
|
||||
};
|
||||
|
||||
return mapa[etapa] ?? etapa;
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -146,13 +146,13 @@
|
||||
<p class="text-sm">{alertData.message}</p>
|
||||
{#if alertType === "access_denied"}
|
||||
<div class="mt-3 flex gap-2">
|
||||
<a href={resolve("/solicitar-acesso")} class="btn btn-sm btn-primary">
|
||||
<a href={resolve("/abrir-chamado")} class="btn btn-sm btn-primary">
|
||||
<svelte:component
|
||||
this={UserPlus}
|
||||
class="h-4 w-4"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
Solicitar Acesso
|
||||
Abrir Chamado
|
||||
</a>
|
||||
<a href={resolve("/ti")} class="btn btn-sm btn-ghost">
|
||||
<svelte:component this={Mail} class="h-4 w-4" strokeWidth={2} />
|
||||
|
||||
247
apps/web/src/routes/(dashboard)/abrir-chamado/+page.svelte
Normal file
247
apps/web/src/routes/(dashboard)/abrir-chamado/+page.svelte
Normal file
@@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import TicketForm from "$lib/components/chamados/TicketForm.svelte";
|
||||
import TicketTimeline from "$lib/components/chamados/TicketTimeline.svelte";
|
||||
import { chamadosStore } from "$lib/stores/chamados";
|
||||
import { resolve } from "$app/paths";
|
||||
|
||||
type Ticket = Doc<"tickets">;
|
||||
type SlaConfig = Doc<"slaConfigs">;
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let slaConfigs = $state<Array<SlaConfig>>([]);
|
||||
let carregandoSla = $state(true);
|
||||
let submitLoading = $state(false);
|
||||
let resetSignal = $state(0);
|
||||
let feedback = $state<{ tipo: "success" | "error"; mensagem: string; numero?: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const exemploTimeline = $state<NonNullable<Ticket["timeline"]>>([
|
||||
{
|
||||
etapa: "abertura",
|
||||
status: "concluido",
|
||||
prazo: Date.now(),
|
||||
concluidoEm: Date.now(),
|
||||
observacao: "Chamado criado",
|
||||
},
|
||||
{
|
||||
etapa: "resposta_inicial",
|
||||
status: "pendente",
|
||||
prazo: Date.now() + 4 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
etapa: "conclusao",
|
||||
status: "pendente",
|
||||
prazo: Date.now() + 24 * 60 * 60 * 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
onMount(() => {
|
||||
carregarSlaConfigs();
|
||||
});
|
||||
|
||||
async function carregarSlaConfigs() {
|
||||
try {
|
||||
carregandoSla = true;
|
||||
const lista = await client.query(api.chamados.listarSlaConfigs, {});
|
||||
slaConfigs = lista ?? [];
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar SLA:", error);
|
||||
slaConfigs = [];
|
||||
} finally {
|
||||
carregandoSla = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadArquivo(file: File) {
|
||||
const uploadUrl = await client.mutation(api.chamados.generateUploadUrl, {});
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": file.type },
|
||||
body: file,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!data?.storageId) {
|
||||
throw new Error("Falha ao enviar arquivo. Tente novamente.");
|
||||
}
|
||||
|
||||
return {
|
||||
arquivoId: data.storageId as Id<"_storage">,
|
||||
nome: file.name,
|
||||
tipo: file.type,
|
||||
tamanho: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmit(event: CustomEvent<{ values: any }>) {
|
||||
const { values } = event.detail;
|
||||
try {
|
||||
submitLoading = true;
|
||||
feedback = null;
|
||||
|
||||
const anexos = [];
|
||||
for (const file of values.anexos ?? []) {
|
||||
const uploaded = await uploadArquivo(file);
|
||||
anexos.push(uploaded);
|
||||
}
|
||||
|
||||
const resultado = await client.mutation(api.chamados.abrirChamado, {
|
||||
titulo: values.titulo,
|
||||
descricao: values.descricao,
|
||||
tipo: values.tipo,
|
||||
categoria: values.categoria,
|
||||
prioridade: values.prioridade,
|
||||
slaConfigId: values.slaConfigId,
|
||||
canalOrigem: values.canalOrigem,
|
||||
anexos,
|
||||
});
|
||||
|
||||
feedback = {
|
||||
tipo: "success",
|
||||
mensagem: "Chamado registrado com sucesso! Você pode acompanhar pelo seu perfil.",
|
||||
numero: resultado.numero,
|
||||
};
|
||||
resetSignal = resetSignal + 1;
|
||||
|
||||
// Atualizar store local
|
||||
const novoTicket = await client.query(api.chamados.obterChamado, {
|
||||
ticketId: resultado.ticketId,
|
||||
});
|
||||
if (novoTicket?.ticket) {
|
||||
chamadosStore.upsertTicket(novoTicket.ticket);
|
||||
chamadosStore.setDetalhe(resultado.ticketId, novoTicket);
|
||||
}
|
||||
} catch (error) {
|
||||
const mensagem =
|
||||
error instanceof Error ? error.message : "Erro ao enviar o chamado. Tente novamente.";
|
||||
feedback = {
|
||||
tipo: "error",
|
||||
mensagem,
|
||||
};
|
||||
} finally {
|
||||
submitLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="mx-auto w-full max-w-6xl space-y-10 px-4 py-8">
|
||||
<section
|
||||
class="relative overflow-hidden rounded-3xl border border-primary/30 bg-linear-to-br from-primary/10 via-base-100 to-secondary/20 p-10 shadow-2xl"
|
||||
>
|
||||
<div class="absolute -left-16 top-0 h-52 w-52 rounded-full bg-primary/20 blur-3xl"></div>
|
||||
<div class="absolute -bottom-20 right-0 h-64 w-64 rounded-full bg-secondary/20 blur-3xl"></div>
|
||||
|
||||
<div class="relative z-10 space-y-4">
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full border border-primary/40 bg-primary/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.28em] text-primary"
|
||||
>
|
||||
Central de Chamados
|
||||
</span>
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div class="max-w-3xl space-y-4">
|
||||
<h1 class="text-4xl font-black leading-tight text-base-content sm:text-5xl">
|
||||
Abrir novo chamado
|
||||
</h1>
|
||||
<p class="text-base text-base-content/70 sm:text-lg">
|
||||
Registre reclamações, sugestões, elogios ou chamados técnicos. Toda interação gera
|
||||
notificações automáticas via e-mail e chat com a assinatura do SGSE.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3 text-sm text-base-content/70">
|
||||
<span class="badge badge-success badge-sm">Resposta ágil</span>
|
||||
<span class="badge badge-info badge-sm">Timeline com SLA</span>
|
||||
<span class="badge badge-warning badge-sm">Alertas de vencimento</span>
|
||||
</div>
|
||||
</div>
|
||||
<a href={resolve("/perfil/chamados")} class="btn btn-outline btn-sm">
|
||||
Acompanhar meus chamados
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if feedback}
|
||||
<div class={`alert ${feedback.tipo === "success" ? "alert-success" : "alert-error"} shadow-lg`}>
|
||||
<div>
|
||||
<span class="font-semibold">{feedback.mensagem}</span>
|
||||
{#if feedback.numero}
|
||||
<p class="text-sm">Número do ticket: {feedback.numero}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-8 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="rounded-3xl border border-base-200 bg-base-100/90 p-6 shadow-xl">
|
||||
<h2 class="text-xl font-semibold text-base-content">Formulário</h2>
|
||||
<p class="text-base-content/60 text-sm">
|
||||
Informe os detalhes para que nossa equipe possa priorizar o atendimento.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
{#if resetSignal % 2 === 0}
|
||||
<TicketForm {slaConfigs} loading={submitLoading} on:submit={handleSubmit} />
|
||||
{:else}
|
||||
<TicketForm {slaConfigs} loading={submitLoading} on:submit={handleSubmit} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="rounded-3xl border border-base-200 bg-base-100/90 p-6 shadow-lg">
|
||||
<h3 class="font-semibold text-base-content">Configurações de SLA</h3>
|
||||
{#if carregandoSla}
|
||||
<div class="flex items-center justify-center py-6">
|
||||
<span class="loading loading-spinner loading-md"></span>
|
||||
</div>
|
||||
{:else if slaConfigs.length === 0}
|
||||
<p class="text-sm text-base-content/60">
|
||||
Nenhuma configuração customizada cadastrada. Os prazos padrão serão aplicados (Resposta:
|
||||
4h, Conclusão: 24h).
|
||||
</p>
|
||||
{:else}
|
||||
<div class="mt-4 space-y-4">
|
||||
{#each slaConfigs as sla (sla._id)}
|
||||
<div class="rounded-2xl border border-primary/20 bg-primary/5 p-4">
|
||||
<p class="text-sm font-semibold text-primary">{sla.nome}</p>
|
||||
<p class="text-xs text-base-content/60">{sla.descricao}</p>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<div class="rounded-xl bg-base-100/90 p-2 text-center">
|
||||
<p class="font-semibold">{sla.tempoRespostaHoras}h</p>
|
||||
<p class="text-base-content/60">Resposta</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-base-100/90 p-2 text-center">
|
||||
<p class="font-semibold">{sla.tempoConclusaoHoras}h</p>
|
||||
<p class="text-base-content/60">Conclusão</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if sla.alertaAntecedenciaHoras}
|
||||
<p class="text-[11px] text-base-content/50 mt-2">
|
||||
Alerta {sla.alertaAntecedenciaHoras}h antes do prazo.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl border border-base-200 bg-base-100/90 p-6 shadow-lg">
|
||||
<h3 class="font-semibold text-base-content">Como funciona a timeline</h3>
|
||||
<p class="text-sm text-base-content/60 mb-4">
|
||||
Todas as etapas do ticket são monitoradas automaticamente. Os prazos mudam de cor conforme
|
||||
o SLA.
|
||||
</p>
|
||||
<TicketTimeline timeline={exemploTimeline} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
321
apps/web/src/routes/(dashboard)/perfil/chamados/+page.svelte
Normal file
321
apps/web/src/routes/(dashboard)/perfil/chamados/+page.svelte
Normal file
@@ -0,0 +1,321 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import TicketCard from "$lib/components/chamados/TicketCard.svelte";
|
||||
import TicketTimeline from "$lib/components/chamados/TicketTimeline.svelte";
|
||||
import { chamadosStore } from "$lib/stores/chamados";
|
||||
import {
|
||||
formatarData,
|
||||
getStatusBadge,
|
||||
getStatusDescription,
|
||||
getStatusLabel,
|
||||
prazoRestante,
|
||||
} from "$lib/utils/chamados";
|
||||
import { resolve } from "$app/paths";
|
||||
|
||||
type Ticket = Doc<"tickets">;
|
||||
|
||||
const client = useConvexClient();
|
||||
const ticketsStore = chamadosStore.tickets;
|
||||
const detalhesStore = chamadosStore.detalhes;
|
||||
|
||||
let carregandoLista = $state(true);
|
||||
let carregandoDetalhe = $state(false);
|
||||
let filtroStatus = $state<"todos" | Ticket["status"]>("todos");
|
||||
let filtroTipo = $state<"todos" | Ticket["tipo"]>("todos");
|
||||
let selectedTicketId = $state<Id<"tickets"> | null>(null);
|
||||
let mensagem = $state("");
|
||||
let erroMensagem = $state<string | null>(null);
|
||||
let sucessoMensagem = $state<string | null>(null);
|
||||
|
||||
const listaChamados = $derived($ticketsStore);
|
||||
const detalheAtual = $derived(
|
||||
selectedTicketId ? ($detalhesStore[selectedTicketId] ?? null) : null
|
||||
);
|
||||
const ticketsFiltrados = $derived(
|
||||
listaChamados.filter((ticket) => {
|
||||
if (filtroStatus !== "todos" && ticket.status !== filtroStatus) return false;
|
||||
if (filtroTipo !== "todos" && ticket.tipo !== filtroTipo) return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
carregarChamados();
|
||||
});
|
||||
|
||||
async function carregarChamados() {
|
||||
try {
|
||||
carregandoLista = true;
|
||||
const data = await client.query(api.chamados.listarChamadosUsuario, {});
|
||||
chamadosStore.setTickets(data ?? []);
|
||||
if (!selectedTicketId && data && data.length > 0) {
|
||||
selecionarChamado(data[0]._id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar chamados:", error);
|
||||
} finally {
|
||||
carregandoLista = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selecionarChamado(ticketId: Id<"tickets">) {
|
||||
selectedTicketId = ticketId;
|
||||
if (!$detalhesStore[ticketId]) {
|
||||
try {
|
||||
carregandoDetalhe = true;
|
||||
const detalhe = await client.query(api.chamados.obterChamado, { ticketId });
|
||||
if (detalhe) {
|
||||
chamadosStore.setDetalhe(ticketId, detalhe);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar detalhe:", error);
|
||||
} finally {
|
||||
carregandoDetalhe = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function enviarMensagem() {
|
||||
if (!selectedTicketId || !mensagem.trim()) {
|
||||
erroMensagem = "Informe uma mensagem para atualizar o chamado.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
erroMensagem = null;
|
||||
sucessoMensagem = null;
|
||||
await client.mutation(api.chamados.registrarAtualizacao, {
|
||||
ticketId: selectedTicketId,
|
||||
conteudo: mensagem.trim(),
|
||||
visibilidade: "publico",
|
||||
});
|
||||
mensagem = "";
|
||||
sucessoMensagem = "Atualização registrada com sucesso.";
|
||||
await selecionarChamado(selectedTicketId);
|
||||
await carregarChamados();
|
||||
} catch (error) {
|
||||
const mensagemErro =
|
||||
error instanceof Error ? error.message : "Erro ao enviar atualização. Tente novamente.";
|
||||
erroMensagem = mensagemErro;
|
||||
}
|
||||
}
|
||||
|
||||
function statusAlertas(ticket: Ticket) {
|
||||
const alertas: Array<{ label: string; tipo: "success" | "warning" | "error" }> = [];
|
||||
if (ticket.prazoResposta) {
|
||||
const diff = ticket.prazoResposta - Date.now();
|
||||
if (diff < 0) alertas.push({ label: "Prazo de resposta vencido", tipo: "error" });
|
||||
else if (diff <= 4 * 60 * 60 * 1000)
|
||||
alertas.push({ label: "Resposta vence em breve", tipo: "warning" });
|
||||
}
|
||||
if (ticket.prazoConclusao) {
|
||||
const diff = ticket.prazoConclusao - Date.now();
|
||||
if (diff < 0) alertas.push({ label: "Prazo de conclusão vencido", tipo: "error" });
|
||||
else if (diff <= 24 * 60 * 60 * 1000)
|
||||
alertas.push({ label: "Conclusão vence em breve", tipo: "warning" });
|
||||
}
|
||||
return alertas;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-8">
|
||||
<section
|
||||
class="rounded-3xl border border-base-200 bg-base-100/90 p-8 shadow-xl"
|
||||
>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.25em] text-primary">Meu Perfil</p>
|
||||
<h1 class="text-3xl font-black text-base-content">Meus Chamados</h1>
|
||||
<p class="text-base-content/70 mt-2 text-sm">
|
||||
Acompanhe o status, interaja com a equipe de TI e visualize a timeline de SLA em tempo real.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href={resolve("/abrir-chamado")} class="btn btn-primary">Abrir novo chamado</a>
|
||||
<button class="btn btn-ghost" type="button" onclick={carregarChamados}>
|
||||
Atualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[340px,1fr]">
|
||||
<aside class="rounded-3xl border border-base-200 bg-base-100/80 p-4 shadow-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-base-content">Meus tickets</h2>
|
||||
{#if carregandoLista}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<select class="select select-sm select-bordered w-full" bind:value={filtroStatus}>
|
||||
<option value="todos">Todos os status</option>
|
||||
<option value="aberto">Aberto</option>
|
||||
<option value="em_andamento">Em andamento</option>
|
||||
<option value="aguardando_usuario">Aguardando usuário</option>
|
||||
<option value="resolvido">Resolvido</option>
|
||||
<option value="encerrado">Encerrado</option>
|
||||
<option value="cancelado">Cancelado</option>
|
||||
</select>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={filtroTipo}>
|
||||
<option value="todos">Todos os tipos</option>
|
||||
<option value="chamado">Chamados técnicos</option>
|
||||
<option value="reclamacao">Reclamações</option>
|
||||
<option value="elogio">Elogios</option>
|
||||
<option value="sugestao">Sugestões</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-3 overflow-y-auto pr-1" style="max-height: calc(100vh - 260px);">
|
||||
{#if ticketsFiltrados.length === 0}
|
||||
<div class="alert alert-info">
|
||||
<span>Nenhum chamado encontrado.</span>
|
||||
</div>
|
||||
{:else}
|
||||
{#each ticketsFiltrados as ticket (ticket._id)}
|
||||
<TicketCard
|
||||
{ticket}
|
||||
selected={ticket._id === selectedTicketId}
|
||||
on:select={({ detail }) => selecionarChamado(detail.ticketId)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="rounded-3xl border border-base-200 bg-base-100/90 p-6 shadow-xl">
|
||||
{#if !selectedTicketId || !detalheAtual}
|
||||
<div class="flex min-h-[400px] items-center justify-center text-base-content/60">
|
||||
{#if carregandoDetalhe}
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
{:else}
|
||||
<p>Selecione um chamado para visualizar os detalhes.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs uppercase text-base-content/60">Ticket {detalheAtual.ticket.numero}</p>
|
||||
<h2 class="text-2xl font-bold text-base-content">{detalheAtual.ticket.titulo}</h2>
|
||||
<p class="text-base-content/70 mt-1 text-sm">{detalheAtual.ticket.descricao}</p>
|
||||
</div>
|
||||
<span class={getStatusBadge(detalheAtual.ticket.status)}>
|
||||
{getStatusLabel(detalheAtual.ticket.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3 text-sm text-base-content/70">
|
||||
<span class="badge badge-outline badge-sm">
|
||||
Tipo: {detalheAtual.ticket.tipo.charAt(0).toUpperCase() + detalheAtual.ticket.tipo.slice(1)}
|
||||
</span>
|
||||
<span class="badge badge-outline badge-sm">
|
||||
Prioridade: {detalheAtual.ticket.prioridade}
|
||||
</span>
|
||||
<span class="badge badge-outline badge-sm">
|
||||
Última interação: {formatarData(detalheAtual.ticket.ultimaInteracaoEm)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if statusAlertas(detalheAtual.ticket).length > 0}
|
||||
<div class="mt-4 space-y-2">
|
||||
{#each statusAlertas(detalheAtual.ticket) as alerta (alerta.label)}
|
||||
<div
|
||||
class={`alert ${
|
||||
alerta.tipo === "error"
|
||||
? "alert-error"
|
||||
: alerta.tipo === "warning"
|
||||
? "alert-warning"
|
||||
: "alert-success"
|
||||
}`}
|
||||
>
|
||||
<span>{alerta.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-base-200 bg-base-100/80 p-4">
|
||||
<h3 class="font-semibold text-base-content">Timeline e SLA</h3>
|
||||
<p class="text-xs text-base-content/60">
|
||||
Etapas monitoradas com indicadores de prazo.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<TicketTimeline timeline={detalheAtual.ticket.timeline ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-base-200 bg-base-100/80 p-4">
|
||||
<h3 class="font-semibold text-base-content">Responsabilidade</h3>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{detalheAtual.ticket.responsavelId
|
||||
? `Responsável: ${detalheAtual.ticket.setorResponsavel ?? "Equipe TI"}`
|
||||
: "Aguardando atribuição"}
|
||||
</p>
|
||||
<div class="mt-4 space-y-2 text-sm text-base-content/70">
|
||||
<p>Prazo resposta: {prazoRestante(detalheAtual.ticket.prazoResposta) ?? "--"}</p>
|
||||
<p>Prazo conclusão: {prazoRestante(detalheAtual.ticket.prazoConclusao) ?? "--"}</p>
|
||||
<p>Prazo encerramento: {prazoRestante(detalheAtual.ticket.prazoEncerramento) ?? "--"}</p>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50 mt-2">
|
||||
{getStatusDescription(detalheAtual.ticket.status)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-base-200 bg-base-100/70 p-4">
|
||||
<h3 class="font-semibold text-base-content">Interações</h3>
|
||||
<div class="mt-4 space-y-3 max-h-[360px] overflow-y-auto pr-2">
|
||||
{#if detalheAtual.interactions.length === 0}
|
||||
<p class="text-sm text-base-content/60">
|
||||
Nenhuma interação registrada ainda.
|
||||
</p>
|
||||
{:else}
|
||||
{#each detalheAtual.interactions as interacao (interacao._id)}
|
||||
<div class="rounded-2xl border border-base-200 bg-base-100/90 p-3">
|
||||
<div class="flex items-center justify-between text-xs text-base-content/60">
|
||||
<span>{interacao.origem === "usuario" ? "Você" : interacao.origem}</span>
|
||||
<span>{formatarData(interacao.criadoEm)}</span>
|
||||
</div>
|
||||
<p class="text-sm text-base-content mt-2 whitespace-pre-wrap">
|
||||
{interacao.conteudo}
|
||||
</p>
|
||||
{#if interacao.statusNovo && interacao.statusNovo !== interacao.statusAnterior}
|
||||
<span class="badge badge-xs badge-outline mt-2">
|
||||
Status: {getStatusLabel(interacao.statusNovo)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-base-200 bg-base-100/70 p-4">
|
||||
<h3 class="font-semibold text-base-content">Enviar atualização</h3>
|
||||
<textarea
|
||||
class="textarea textarea-bordered mt-3 min-h-[140px] w-full"
|
||||
placeholder="Compartilhe informações adicionais, aprovações ou anexos enviados por outros canais."
|
||||
bind:value={mensagem}
|
||||
></textarea>
|
||||
{#if erroMensagem}
|
||||
<p class="text-error mt-2 text-sm">{erroMensagem}</p>
|
||||
{/if}
|
||||
{#if sucessoMensagem}
|
||||
<p class="text-success mt-2 text-sm">{sucessoMensagem}</p>
|
||||
{/if}
|
||||
<button type="button" class="btn btn-primary mt-3 w-full" onclick={enviarMensagem}>
|
||||
Enviar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { createForm } from '@tanstack/svelte-form';
|
||||
import z from 'zod';
|
||||
|
||||
const convex = useConvexClient();
|
||||
|
||||
// Estado para mensagens
|
||||
let notice = $state<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
|
||||
// Schema de validação
|
||||
const formSchema = z.object({
|
||||
nome: z.string().min(3, 'Nome deve ter no mínimo 3 caracteres'),
|
||||
matricula: z.string().min(1, 'Matrícula é obrigatória'),
|
||||
email: z.string().email('E-mail inválido'),
|
||||
telefone: z.string().min(14, 'Telefone inválido')
|
||||
});
|
||||
|
||||
// Criar o formulário
|
||||
const form = createForm(() => ({
|
||||
defaultValues: {
|
||||
nome: '',
|
||||
matricula: '',
|
||||
email: '',
|
||||
telefone: ''
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
try {
|
||||
notice = null;
|
||||
await convex.mutation(api.solicitacoesAcesso.create, {
|
||||
nome: value.nome,
|
||||
matricula: value.matricula,
|
||||
email: value.email,
|
||||
telefone: value.telefone
|
||||
});
|
||||
notice = {
|
||||
type: 'success',
|
||||
message: 'Solicitação de acesso enviada com sucesso! Aguarde a análise da equipe de TI.'
|
||||
};
|
||||
// Limpar o formulário
|
||||
form.reset();
|
||||
// Redirecionar após 3 segundos
|
||||
setTimeout(() => {
|
||||
goto(resolve('/'));
|
||||
}, 3000);
|
||||
} catch (error: any) {
|
||||
notice = {
|
||||
type: 'error',
|
||||
message: error.message || 'Erro ao enviar solicitação. Tente novamente.'
|
||||
};
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Máscaras
|
||||
function maskTelefone(value: string): string {
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
if (cleaned.length <= 10) {
|
||||
return cleaned.replace(/^(\d{2})(\d)/, '($1) $2').replace(/(\d{4})(\d)/, '$1-$2');
|
||||
}
|
||||
return cleaned.replace(/^(\d{2})(\d)/, '($1) $2').replace(/(\d{5})(\d)/, '$1-$2');
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
goto(resolve('/'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="mx-auto w-full max-w-4xl space-y-8 px-4 py-10">
|
||||
<!-- Cabeçalho Estilizado -->
|
||||
<section
|
||||
class="border-primary/25 from-primary/10 via-base-100 to-secondary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl"
|
||||
>
|
||||
<div class="bg-primary/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
|
||||
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
|
||||
<div class="relative z-10 space-y-4">
|
||||
<span
|
||||
class="border-primary/40 bg-primary/10 text-primary inline-flex w-fit items-center gap-2 rounded-full border px-4 py-1 text-xs font-semibold tracking-[0.28em] uppercase"
|
||||
>
|
||||
Acesso ao Sistema
|
||||
</span>
|
||||
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
|
||||
Solicitar Acesso ao SGSE
|
||||
</h1>
|
||||
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Alertas -->
|
||||
{#if notice}
|
||||
<div class="alert {notice.type === 'success' ? 'alert-success' : 'alert-error'} shadow-xl">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{#if notice.type === 'success'}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
<span class="font-semibold">{notice.message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Formulário -->
|
||||
<section
|
||||
class="border-base-200 bg-base-100/90 relative overflow-hidden rounded-3xl border p-8 shadow-2xl"
|
||||
>
|
||||
<div
|
||||
class="from-base-200/40 absolute inset-x-6 top-0 h-24 rounded-b-full bg-linear-to-b to-transparent opacity-50"
|
||||
></div>
|
||||
<div class="relative z-10">
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Nome -->
|
||||
<form.Field name="nome" validators={{ onChange: formSchema.shape.nome }}>
|
||||
{#snippet children(field)}
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="nome">
|
||||
<span class="label-text font-semibold">Nome Completo</span>
|
||||
<span class="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="nome"
|
||||
type="text"
|
||||
placeholder="Digite seu nome completo"
|
||||
class="input input-bordered focus:input-primary w-full transition-colors duration-300"
|
||||
value={field.state.value}
|
||||
onblur={field.handleBlur}
|
||||
oninput={(e) => field.handleChange(e.currentTarget.value)}
|
||||
/>
|
||||
{#if field.state.meta.errors.length > 0}
|
||||
<label class="label">
|
||||
<span class="text-error mt-1 text-sm">{field.state.meta.errors[0]}</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</form.Field>
|
||||
|
||||
<!-- Matrícula -->
|
||||
<form.Field name="matricula" validators={{ onChange: formSchema.shape.matricula }}>
|
||||
{#snippet children(field)}
|
||||
<div class="form-control">
|
||||
<label class="label" for="matricula">
|
||||
<span class="label-text font-semibold">Matrícula</span>
|
||||
<span class="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="matricula"
|
||||
type="text"
|
||||
placeholder="Digite sua matrícula"
|
||||
class="input input-bordered focus:input-primary w-full transition-colors duration-300"
|
||||
value={field.state.value}
|
||||
onblur={field.handleBlur}
|
||||
oninput={(e) => field.handleChange(e.currentTarget.value)}
|
||||
/>
|
||||
{#if field.state.meta.errors.length > 0}
|
||||
<label class="label">
|
||||
<span class="text-error mt-1 text-sm">{field.state.meta.errors[0]}</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</form.Field>
|
||||
|
||||
<!-- E-mail -->
|
||||
<form.Field name="email" validators={{ onChange: formSchema.shape.email }}>
|
||||
{#snippet children(field)}
|
||||
<div class="form-control">
|
||||
<label class="label" for="email">
|
||||
<span class="label-text font-semibold">E-mail</span>
|
||||
<span class="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="seu@email.com"
|
||||
class="input input-bordered focus:input-primary w-full transition-colors duration-300"
|
||||
value={field.state.value}
|
||||
onblur={field.handleBlur}
|
||||
oninput={(e) => field.handleChange(e.currentTarget.value)}
|
||||
/>
|
||||
{#if field.state.meta.errors.length > 0}
|
||||
<label class="label">
|
||||
<span class="text-error mt-1 text-sm">{field.state.meta.errors[0]}</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</form.Field>
|
||||
|
||||
<!-- Telefone -->
|
||||
<form.Field name="telefone" validators={{ onChange: formSchema.shape.telefone }}>
|
||||
{#snippet children(field)}
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="telefone">
|
||||
<span class="label-text font-semibold">Telefone</span>
|
||||
<span class="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="telefone"
|
||||
type="text"
|
||||
placeholder="(00) 00000-0000"
|
||||
class="input input-bordered focus:input-primary w-full transition-colors duration-300"
|
||||
value={field.state.value}
|
||||
onblur={field.handleBlur}
|
||||
oninput={(e) => {
|
||||
const masked = maskTelefone(e.currentTarget.value);
|
||||
e.currentTarget.value = masked;
|
||||
field.handleChange(masked);
|
||||
}}
|
||||
maxlength="15"
|
||||
/>
|
||||
{#if field.state.meta.errors.length > 0}
|
||||
<label class="label">
|
||||
<span class="text-error mt-1 text-sm">{field.state.meta.errors[0]}</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<!-- Botões de Ação -->
|
||||
<div class="border-base-300 mt-8 flex justify-end gap-4 border-t pt-6">
|
||||
<button type="button" class="btn btn-md" onclick={handleCancel}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-md hover:shadow-primary/40 shadow-md transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Solicitar Acesso
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Informações Importantes -->
|
||||
<div class="alert alert-info border-info/30 border shadow-xl">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="mb-2 text-lg font-bold">Informações Importantes</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-info font-bold">•</span>
|
||||
<span>Todos os campos marcados com * são obrigatórios</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-info font-bold">•</span>
|
||||
<span>Sua solicitação será analisada pela equipe de TI em até 48 horas úteis</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-info font-bold">•</span>
|
||||
<span>Você receberá um e-mail com o resultado da análise</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-info font-bold">•</span>
|
||||
<span>Em caso de dúvidas, entre em contato com o suporte técnico</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
}
|
||||
</style>
|
||||
@@ -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:
|
||||
|
||||
388
apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte
Normal file
388
apps/web/src/routes/(dashboard)/ti/central-chamados/+page.svelte
Normal file
@@ -0,0 +1,388 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { useConvexClient, useQuery } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import TicketTimeline from "$lib/components/chamados/TicketTimeline.svelte";
|
||||
import {
|
||||
formatarData,
|
||||
getStatusBadge,
|
||||
getStatusLabel,
|
||||
prazoRestante,
|
||||
} from "$lib/utils/chamados";
|
||||
|
||||
type Ticket = Doc<"tickets">;
|
||||
type Usuario = Doc<"usuarios">;
|
||||
type SlaConfig = Doc<"slaConfigs">;
|
||||
|
||||
const client = useConvexClient();
|
||||
const usuariosQuery = useQuery(api.usuarios.listar, {});
|
||||
const slaConfigsQuery = useQuery(api.chamados.listarSlaConfigs, {});
|
||||
|
||||
let carregandoChamados = $state(true);
|
||||
let tickets = $state<Array<Ticket>>([]);
|
||||
let filtroStatus = $state<"todos" | Ticket["status"]>("todos");
|
||||
let filtroResponsavel = $state<"todos" | Id<"usuarios">>("todos");
|
||||
let filtroSetor = $state<string>("todos");
|
||||
let ticketSelecionado = $state<Id<"tickets"> | null>(null);
|
||||
let detalheSelecionado = $state<Ticket | null>(null);
|
||||
let assignResponsavel = $state<Id<"usuarios"> | "">("");
|
||||
let assignMotivo = $state("");
|
||||
let assignFeedback = $state<string | null>(null);
|
||||
let slaForm = $state<{
|
||||
slaId?: Id<"slaConfigs">;
|
||||
nome: string;
|
||||
descricao: string;
|
||||
tempoRespostaHoras: number;
|
||||
tempoConclusaoHoras: number;
|
||||
tempoEncerramentoHoras?: number | null;
|
||||
alertaAntecedenciaHoras: number;
|
||||
ativo: boolean;
|
||||
}>({
|
||||
nome: "",
|
||||
descricao: "",
|
||||
tempoRespostaHoras: 4,
|
||||
tempoConclusaoHoras: 24,
|
||||
tempoEncerramentoHoras: 72,
|
||||
alertaAntecedenciaHoras: 2,
|
||||
ativo: true,
|
||||
});
|
||||
let slaFeedback = $state<string | null>(null);
|
||||
|
||||
let carregamentoToken = 0;
|
||||
$effect(() => {
|
||||
const filtros = {
|
||||
status: filtroStatus === "todos" ? undefined : filtroStatus,
|
||||
responsavelId: filtroResponsavel === "todos" ? undefined : filtroResponsavel,
|
||||
setor: filtroSetor === "todos" ? undefined : filtroSetor,
|
||||
};
|
||||
carregarChamados(filtros);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (slaConfigsQuery?.data && slaConfigsQuery.data.length > 0) {
|
||||
selecionarSla(slaConfigsQuery.data[0]);
|
||||
}
|
||||
});
|
||||
|
||||
async function carregarChamados(filtros: {
|
||||
status?: Ticket["status"];
|
||||
responsavelId?: Id<"usuarios">;
|
||||
setor?: string;
|
||||
}) {
|
||||
try {
|
||||
carregandoChamados = true;
|
||||
const token = ++carregamentoToken;
|
||||
const data = await client.query(api.chamados.listarChamadosTI, {
|
||||
status: filtros.status,
|
||||
responsavelId: filtros.responsavelId,
|
||||
setor: filtros.setor,
|
||||
});
|
||||
if (token !== carregamentoToken) return;
|
||||
tickets = data ?? [];
|
||||
if (!ticketSelecionado && tickets.length > 0) {
|
||||
selecionarTicket(tickets[0]._id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar chamados:", error);
|
||||
} finally {
|
||||
carregandoChamados = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selecionarTicket(ticketId: Id<"tickets">) {
|
||||
ticketSelecionado = ticketId;
|
||||
detalheSelecionado = tickets.find((t) => t._id === ticketId) ?? null;
|
||||
}
|
||||
|
||||
const usuariosTI = $derived(
|
||||
(usuariosQuery?.data || []).filter((usuario: Usuario) => usuario.setor === "TI")
|
||||
);
|
||||
|
||||
const estatisticas = $derived(() => {
|
||||
const total = tickets.length;
|
||||
const abertos = tickets.filter((t) => t.status === "aberto").length;
|
||||
const emAndamento = tickets.filter((t) => t.status === "em_andamento").length;
|
||||
const vencidos = tickets.filter(
|
||||
(t) => (t.prazoConclusao && t.prazoConclusao < Date.now()) || t.status === "cancelado"
|
||||
).length;
|
||||
return { total, abertos, emAndamento, vencidos };
|
||||
});
|
||||
|
||||
function selecionarSla(sla: SlaConfig) {
|
||||
slaForm = {
|
||||
slaId: sla._id,
|
||||
nome: sla.nome,
|
||||
descricao: sla.descricao ?? "",
|
||||
tempoRespostaHoras: sla.tempoRespostaHoras,
|
||||
tempoConclusaoHoras: sla.tempoConclusaoHoras,
|
||||
tempoEncerramentoHoras: sla.tempoEncerramentoHoras ?? undefined,
|
||||
alertaAntecedenciaHoras: sla.alertaAntecedenciaHoras,
|
||||
ativo: sla.ativo,
|
||||
};
|
||||
}
|
||||
|
||||
async function salvarSlaConfig() {
|
||||
try {
|
||||
slaFeedback = null;
|
||||
await client.mutation(api.chamados.salvarSlaConfig, {
|
||||
...slaForm,
|
||||
tempoEncerramentoHoras: slaForm.tempoEncerramentoHoras,
|
||||
});
|
||||
slaFeedback = "Configuração salva com sucesso.";
|
||||
} catch (error) {
|
||||
slaFeedback =
|
||||
error instanceof Error ? error.message : "Erro ao salvar configuração de SLA.";
|
||||
}
|
||||
}
|
||||
|
||||
async function atribuirResponsavel() {
|
||||
if (!ticketSelecionado || !assignResponsavel) {
|
||||
assignFeedback = "Escolha um ticket e um responsável.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
assignFeedback = null;
|
||||
await client.mutation(api.chamados.atribuirResponsavel, {
|
||||
ticketId: ticketSelecionado,
|
||||
responsavelId: assignResponsavel as Id<"usuarios">,
|
||||
motivo: assignMotivo || undefined,
|
||||
});
|
||||
assignFeedback = "Responsável atribuído.";
|
||||
assignMotivo = "";
|
||||
await carregarChamados({
|
||||
status: filtroStatus === "todos" ? undefined : filtroStatus,
|
||||
responsavelId: filtroResponsavel === "todos" ? undefined : filtroResponsavel,
|
||||
setor: filtroSetor === "todos" ? undefined : filtroSetor,
|
||||
});
|
||||
if (ticketSelecionado) {
|
||||
selecionarTicket(ticketSelecionado);
|
||||
}
|
||||
} catch (error) {
|
||||
assignFeedback = error instanceof Error ? error.message : "Erro ao atribuir responsável.";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-8">
|
||||
<section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-2xl border border-primary/20 bg-primary/5 p-4">
|
||||
<p class="text-sm text-base-content/60">Total de chamados</p>
|
||||
<p class="text-3xl font-bold text-primary">{estatisticas.total}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-info/20 bg-info/5 p-4">
|
||||
<p class="text-sm text-base-content/60">Abertos</p>
|
||||
<p class="text-3xl font-bold text-info">{estatisticas.abertos}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-warning/20 bg-warning/5 p-4">
|
||||
<p class="text-sm text-base-content/60">Em andamento</p>
|
||||
<p class="text-3xl font-bold text-warning">{estatisticas.emAndamento}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-error/20 bg-error/5 p-4">
|
||||
<p class="text-sm text-base-content/60">Vencidos/Cancelados</p>
|
||||
<p class="text-3xl font-bold text-error">{estatisticas.vencidos}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-base-200 bg-base-100/90 p-6 shadow-xl">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-base-content">Painel de chamados</h2>
|
||||
<p class="text-sm text-base-content/60">
|
||||
Filtros por status, responsável e setor.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<select class="select select-sm select-bordered" bind:value={filtroStatus}>
|
||||
<option value="todos">Todos os status</option>
|
||||
<option value="aberto">Aberto</option>
|
||||
<option value="em_andamento">Em andamento</option>
|
||||
<option value="aguardando_usuario">Aguardando usuário</option>
|
||||
<option value="resolvido">Resolvido</option>
|
||||
<option value="encerrado">Encerrado</option>
|
||||
<option value="cancelado">Cancelado</option>
|
||||
</select>
|
||||
<select class="select select-sm select-bordered" bind:value={filtroResponsavel}>
|
||||
<option value="todos">Todos os responsáveis</option>
|
||||
{#each usuariosTI as usuario (usuario._id)}
|
||||
<option value={usuario._id}>{usuario.nome}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select class="select select-sm select-bordered" bind:value={filtroSetor}>
|
||||
<option value="todos">Todos os setores</option>
|
||||
<option value="TI">TI</option>
|
||||
<option value="Infraestrutura">Infraestrutura</option>
|
||||
<option value="Sistemas">Sistemas</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticket</th>
|
||||
<th>Tipo</th>
|
||||
<th>Status</th>
|
||||
<th>Responsável</th>
|
||||
<th>Prioridade</th>
|
||||
<th>Prazo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if carregandoChamados}
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-md"></span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else if tickets.length === 0}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-sm text-base-content/60">
|
||||
Nenhum chamado encontrado.
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each tickets as ticket (ticket._id)}
|
||||
<tr
|
||||
class={ticketSelecionado === ticket._id ? "bg-primary/5" : ""}
|
||||
onclick={() => selecionarTicket(ticket._id)}
|
||||
>
|
||||
<td>
|
||||
<div class="font-semibold">{ticket.numero}</div>
|
||||
<div class="text-xs text-base-content/60">{ticket.solicitanteNome}</div>
|
||||
</td>
|
||||
<td class="text-sm capitalize">{ticket.tipo}</td>
|
||||
<td>
|
||||
<span class={getStatusBadge(ticket.status)}>{getStatusLabel(ticket.status)}</span>
|
||||
</td>
|
||||
<td class="text-sm">{ticket.setorResponsavel ?? "—"}</td>
|
||||
<td class="text-sm capitalize">{ticket.prioridade}</td>
|
||||
<td class="text-xs text-base-content/70">
|
||||
{ticket.prazoConclusao ? prazoRestante(ticket.prazoConclusao) : "--"}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 lg:grid-cols-2">
|
||||
<div class="rounded-3xl border border-base-200 bg-base-100/80 p-6 shadow-lg">
|
||||
<h3 class="text-lg font-semibold text-base-content">Detalhes do chamado</h3>
|
||||
{#if !detalheSelecionado}
|
||||
<p class="text-sm text-base-content/60">Selecione um chamado na tabela.</p>
|
||||
{:else}
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-base-content/60">Solicitante</p>
|
||||
<p class="font-semibold text-base-content">{detalheSelecionado.solicitanteNome}</p>
|
||||
</div>
|
||||
<span class={getStatusBadge(detalheSelecionado.status)}>
|
||||
{getStatusLabel(detalheSelecionado.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/70">{detalheSelecionado.descricao}</p>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm text-base-content/70">
|
||||
<div>
|
||||
<p class="text-xs text-base-content/50">Prazo resposta</p>
|
||||
<p>{prazoRestante(detalheSelecionado.prazoResposta) ?? "--"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/50">Prazo conclusão</p>
|
||||
<p>{prazoRestante(detalheSelecionado.prazoConclusao) ?? "--"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<TicketTimeline timeline={detalheSelecionado.timeline ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="rounded-3xl border border-base-200 bg-base-100/80 p-6 shadow-lg">
|
||||
<h3 class="text-lg font-semibold text-base-content">Atribuir responsável</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
<select class="select select-bordered w-full" bind:value={assignResponsavel}>
|
||||
<option value="">Selecione o responsável</option>
|
||||
{#each usuariosTI as usuario (usuario._id)}
|
||||
<option value={usuario._id}>{usuario.nome}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<textarea
|
||||
class="textarea textarea-bordered w-full"
|
||||
rows="3"
|
||||
placeholder="Motivo/observação"
|
||||
bind:value={assignMotivo}
|
||||
></textarea>
|
||||
{#if assignFeedback}
|
||||
<p class="text-sm text-base-content/70">{assignFeedback}</p>
|
||||
{/if}
|
||||
<button class="btn btn-primary w-full" type="button" onclick={atribuirResponsavel}>
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-base-200 bg-base-100/80 p-6 shadow-xl">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-base-content">Configuração de SLA</h3>
|
||||
<p class="text-sm text-base-content/60">Defina tempos de resposta, conclusão e alertas.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#if slaConfigsQuery?.data}
|
||||
{#each slaConfigsQuery.data as sla (sla._id)}
|
||||
<button class="btn btn-sm" type="button" onclick={() => selecionarSla(sla)}>
|
||||
{sla.nome}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Nome</span></label>
|
||||
<input class="input input-bordered w-full" bind:value={slaForm.nome} />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Descrição</span></label>
|
||||
<input class="input input-bordered w-full" bind:value={slaForm.descricao} />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Tempo de resposta (h)</span></label>
|
||||
<input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.tempoRespostaHoras} />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Tempo de conclusão (h)</span></label>
|
||||
<input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.tempoConclusaoHoras} />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Auto-encerramento (h)</span></label>
|
||||
<input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.tempoEncerramentoHoras} />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text font-semibold">Alerta antes do vencimento (h)</span></label>
|
||||
<input type="number" min="1" class="input input-bordered w-full" bind:value={slaForm.alertaAntecedenciaHoras} />
|
||||
</div>
|
||||
<label class="label cursor-pointer gap-3">
|
||||
<span class="label-text font-semibold">Ativo</span>
|
||||
<input type="checkbox" class="toggle toggle-primary" bind:checked={slaForm.ativo} />
|
||||
</label>
|
||||
</div>
|
||||
{#if slaFeedback}
|
||||
<p class="text-sm text-base-content/70 mt-3">{slaFeedback}</p>
|
||||
{/if}
|
||||
<button class="btn btn-primary mt-4" type="button" onclick={salvarSlaConfig}>
|
||||
Salvar configuração
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
2
packages/backend/convex/_generated/api.d.ts
vendored
2
packages/backend/convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
|
||||
585
packages/backend/convex/chamados.ts
Normal file
585
packages/backend/convex/chamados.ts
Normal file
@@ -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<typeof getCurrentUserFunction>[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<typeof calcularPrazos>) {
|
||||
const timeline: NonNullable<TicketDoc["timeline"]> = [
|
||||
{
|
||||
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<typeof getCurrentUserFunction>[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<Doc<"tickets">> = [];
|
||||
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"]),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user