Merge remote-tracking branch 'origin' into feat-pedidos
This commit is contained in:
@@ -234,7 +234,7 @@
|
||||
icon: 'control'
|
||||
},
|
||||
{
|
||||
title: 'Cibersecurity SGSE - Sistema de Gerenciamento de Secretaria',
|
||||
title: 'Cibersecurity SGSE - Central de Segurança Cibernética',
|
||||
description:
|
||||
'Central desegurança cibernética com detecção de DDoS, SQLi, APT, bloqueios automatizados, relatórios refinados e alertas sonoros/visuais.',
|
||||
ctaLabel: 'Abrir Central',
|
||||
@@ -433,15 +433,16 @@
|
||||
|
||||
<section class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{#each featureCards as card (card.title)}
|
||||
<article
|
||||
class={`card-hover group relative overflow-hidden rounded-2xl border ${paletteStyles[card.palette].cardBorder} bg-base-100/90 p-6 shadow-lg transition-all duration-300`}
|
||||
>
|
||||
<div
|
||||
class="from-base-200/40 absolute inset-x-6 top-0 h-24 rounded-b-full bg-linear-to-b to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
></div>
|
||||
<div class="relative flex items-start gap-4">
|
||||
{#if card.href && !card.disabled}
|
||||
<a
|
||||
href={resolve(card.href)}
|
||||
class={`group relative flex cursor-pointer items-center gap-4 overflow-hidden rounded-2xl border ${paletteStyles[card.palette].cardBorder} bg-base-100/90 p-6 shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.02]`}
|
||||
>
|
||||
<div
|
||||
class={`flex h-14 w-14 items-center justify-center rounded-2xl ${paletteStyles[card.palette].iconBg} ${paletteStyles[card.palette].iconRing}`}
|
||||
class="from-base-200/40 absolute inset-x-6 top-0 h-24 rounded-b-full bg-linear-to-b to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
></div>
|
||||
<div
|
||||
class={`relative flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl ${paletteStyles[card.palette].iconBg} ${paletteStyles[card.palette].iconRing}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -464,47 +465,39 @@
|
||||
<h2 class="text-base-content text-xl font-semibold">
|
||||
{card.title}
|
||||
</h2>
|
||||
<p class="text-base-content/70 mt-2 text-sm leading-relaxed">
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if card.highlightBadges}
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{#each card.highlightBadges as badge (badge.label)}
|
||||
{#if badge.variant === 'solid'}
|
||||
<span class={`badge ${paletteStyles[card.palette].badgeSolid}`}>{badge.label}</span>
|
||||
{:else}
|
||||
<span
|
||||
class={`badge ${paletteStyles[card.palette].badgeOutline} ${paletteStyles[card.palette].iconColor}`}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</a>
|
||||
{:else}
|
||||
<article
|
||||
class={`group relative flex cursor-not-allowed items-center gap-4 overflow-hidden rounded-2xl border ${paletteStyles[card.palette].cardBorder} bg-base-100/50 p-6 shadow-lg opacity-60`}
|
||||
>
|
||||
<div
|
||||
class={`relative flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl ${paletteStyles[card.palette].iconBg} ${paletteStyles[card.palette].iconRing}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
class={`h-7 w-7 ${paletteStyles[card.palette].iconColor}`}
|
||||
>
|
||||
{#each iconPaths[card.icon] as path (path.d)}
|
||||
<path
|
||||
d={path.d}
|
||||
stroke-linecap={path.strokeLinecap ?? 'round'}
|
||||
stroke-linejoin={path.strokeLinejoin ?? 'round'}
|
||||
stroke-width={path.strokeWidth ?? 2}
|
||||
/>
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
{#if card.href && !card.disabled}
|
||||
<a
|
||||
class={`btn ${paletteStyles[card.palette].button} btn-sm sm:btn-md shadow-md transition-all duration-200 hover:shadow-lg`}
|
||||
href={resolve(card.href)}
|
||||
>
|
||||
{card.ctaLabel}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class={`btn ${paletteStyles[card.palette].button} btn-sm sm:btn-md shadow-md`}
|
||||
disabled
|
||||
>
|
||||
{card.ctaLabel}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
<div class="relative flex-1">
|
||||
<h2 class="text-base-content text-xl font-semibold">
|
||||
{card.title}
|
||||
</h2>
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,26 +1,190 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from "convex-svelte";
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
|
||||
|
||||
import { resolve } from "$app/paths";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import jsPDF from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import Papa from "papaparse";
|
||||
import logoGovPE from "$lib/assets/logo_governo_PE.png";
|
||||
|
||||
let abaAtiva = $state<"atividades" | "logins">("atividades");
|
||||
let limite = $state(50);
|
||||
let mostrarFiltros = $state(false);
|
||||
let exportando = $state(false);
|
||||
const client = useConvexClient();
|
||||
|
||||
// Queries com $derived para garantir reatividade
|
||||
const atividades = $derived(useQuery(api.logsAtividades.listarAtividades, { limite }));
|
||||
const logins = $derived(useQuery(api.logsLogin.listarTodosLogins, { limite }));
|
||||
// Filtros avançados
|
||||
let filtroDataInicio = $state<string>("");
|
||||
let filtroDataFim = $state<string>("");
|
||||
let filtroUsuario = $state<string>("");
|
||||
let filtroStatus = $state<"todos" | "sucesso" | "falha">("todos");
|
||||
let filtroIP = $state<string>("");
|
||||
|
||||
function formatarData(timestamp: number) {
|
||||
return new Date(timestamp).toLocaleString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
// Queries reativas do Convex - usar função para garantir reatividade do limite
|
||||
const atividadesQuery = useQuery(api.logsAtividades.listarAtividades, () => ({ limite }));
|
||||
const loginsQuery = useQuery(api.logsLogin.listarTodosLogins, () => ({ limite }));
|
||||
|
||||
// Extrair dados das queries de forma robusta
|
||||
const atividadesRaw = $derived.by(() => {
|
||||
if (atividadesQuery === undefined || atividadesQuery === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof atividadesQuery === 'object' && Object.keys(atividadesQuery).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if ('data' in atividadesQuery && atividadesQuery.data !== undefined) {
|
||||
return Array.isArray(atividadesQuery.data) ? atividadesQuery.data : undefined;
|
||||
}
|
||||
if (Array.isArray(atividadesQuery)) {
|
||||
return atividadesQuery;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const loginsRaw = $derived.by(() => {
|
||||
if (loginsQuery === undefined || loginsQuery === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof loginsQuery === 'object' && Object.keys(loginsQuery).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if ('data' in loginsQuery && loginsQuery.data !== undefined) {
|
||||
return Array.isArray(loginsQuery.data) ? loginsQuery.data : undefined;
|
||||
}
|
||||
if (Array.isArray(loginsQuery)) {
|
||||
return loginsQuery;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Aplicar filtros
|
||||
const atividades = $derived.by(() => {
|
||||
if (!atividadesRaw || !Array.isArray(atividadesRaw)) return [];
|
||||
let filtradas = [...atividadesRaw];
|
||||
|
||||
if (filtroDataInicio) {
|
||||
const dataInicio = new Date(filtroDataInicio).getTime();
|
||||
filtradas = filtradas.filter(a => a.timestamp >= dataInicio);
|
||||
}
|
||||
if (filtroDataFim) {
|
||||
const dataFim = new Date(filtroDataFim + "T23:59:59").getTime();
|
||||
filtradas = filtradas.filter(a => a.timestamp <= dataFim);
|
||||
}
|
||||
if (filtroUsuario) {
|
||||
const usuarioLower = filtroUsuario.toLowerCase();
|
||||
filtradas = filtradas.filter(a =>
|
||||
(a.usuarioNome?.toLowerCase().includes(usuarioLower)) ||
|
||||
(a.usuarioMatricula?.toLowerCase().includes(usuarioLower))
|
||||
);
|
||||
}
|
||||
|
||||
return filtradas;
|
||||
});
|
||||
|
||||
const logins = $derived.by(() => {
|
||||
if (!loginsRaw || !Array.isArray(loginsRaw)) return [];
|
||||
let filtrados = [...loginsRaw];
|
||||
|
||||
if (filtroDataInicio) {
|
||||
const dataInicio = new Date(filtroDataInicio).getTime();
|
||||
filtrados = filtrados.filter(l => l.timestamp >= dataInicio);
|
||||
}
|
||||
if (filtroDataFim) {
|
||||
const dataFim = new Date(filtroDataFim + "T23:59:59").getTime();
|
||||
filtrados = filtrados.filter(l => l.timestamp <= dataFim);
|
||||
}
|
||||
if (filtroUsuario) {
|
||||
const usuarioLower = filtroUsuario.toLowerCase();
|
||||
filtrados = filtrados.filter(l => {
|
||||
const nomeMatch = l.usuarioNome?.toLowerCase().includes(usuarioLower);
|
||||
const emailMatch = l.usuarioEmail?.toLowerCase().includes(usuarioLower);
|
||||
const matriculaMatch = l.matriculaOuEmail?.toLowerCase().includes(usuarioLower);
|
||||
return nomeMatch || emailMatch || matriculaMatch;
|
||||
});
|
||||
}
|
||||
if (filtroStatus !== "todos") {
|
||||
filtrados = filtrados.filter(l =>
|
||||
filtroStatus === "sucesso" ? l.sucesso : !l.sucesso
|
||||
);
|
||||
}
|
||||
if (filtroIP) {
|
||||
filtrados = filtrados.filter(l =>
|
||||
l.ipAddress?.includes(filtroIP)
|
||||
);
|
||||
}
|
||||
|
||||
return filtrados;
|
||||
});
|
||||
|
||||
function formatarData(timestamp: number) {
|
||||
return new Date(timestamp).toLocaleString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatarLocalizacao(login: any): string {
|
||||
if (!login || typeof login !== 'object') return "-";
|
||||
|
||||
// Priorizar localização GPS (mais precisa) quando disponível
|
||||
const usarGPS = login.latitudeGPS && login.longitudeGPS;
|
||||
|
||||
if (usarGPS) {
|
||||
const partes: string[] = [];
|
||||
if (login.cidadeGPS && typeof login.cidadeGPS === 'string' && login.cidadeGPS.trim()) {
|
||||
partes.push(login.cidadeGPS.trim());
|
||||
}
|
||||
if (login.estadoGPS && typeof login.estadoGPS === 'string' && login.estadoGPS.trim()) {
|
||||
partes.push(login.estadoGPS.trim());
|
||||
}
|
||||
if (login.paisGPS && typeof login.paisGPS === 'string' && login.paisGPS.trim()) {
|
||||
partes.push(login.paisGPS.trim());
|
||||
}
|
||||
|
||||
if (partes.length > 0) {
|
||||
return `${partes.join(", ")} (GPS)`;
|
||||
}
|
||||
|
||||
// Se não tiver cidade/estado/pais GPS, mas tiver endereco GPS, mostrar endereco
|
||||
if (login.enderecoGPS && typeof login.enderecoGPS === 'string' && login.enderecoGPS.trim()) {
|
||||
return `${login.enderecoGPS.trim()} (GPS)`;
|
||||
}
|
||||
|
||||
// Se tiver coordenadas mas não endereço, mostrar coordenadas
|
||||
if (login.latitudeGPS && login.longitudeGPS) {
|
||||
return `${login.latitudeGPS.toFixed(6)}, ${login.longitudeGPS.toFixed(6)} (GPS)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback para localização por IP
|
||||
const partes: string[] = [];
|
||||
if (login.cidade && typeof login.cidade === 'string' && login.cidade.trim()) {
|
||||
partes.push(login.cidade.trim());
|
||||
}
|
||||
if (login.estado && typeof login.estado === 'string' && login.estado.trim()) {
|
||||
partes.push(login.estado.trim());
|
||||
}
|
||||
if (login.pais && typeof login.pais === 'string' && login.pais.trim()) {
|
||||
partes.push(login.pais.trim());
|
||||
}
|
||||
|
||||
if (partes.length > 0) {
|
||||
return partes.join(", ");
|
||||
}
|
||||
|
||||
// Se não tiver cidade/estado/pais, mas tiver endereco, mostrar endereco
|
||||
if (login.endereco && typeof login.endereco === 'string' && login.endereco.trim()) {
|
||||
return login.endereco.trim();
|
||||
}
|
||||
|
||||
return "-";
|
||||
}
|
||||
|
||||
function getAcaoColor(acao: string) {
|
||||
const colors: Record<string, string> = {
|
||||
@@ -47,10 +211,215 @@
|
||||
}
|
||||
|
||||
// Estatísticas
|
||||
const totalAtividades = $derived(atividades?.data?.length || 0);
|
||||
const totalLogins = $derived(logins?.data?.length || 0);
|
||||
const loginsSucesso = $derived(logins?.data?.filter(l => l.sucesso).length || 0);
|
||||
const loginsFalha = $derived(logins?.data?.filter(l => !l.sucesso).length || 0);
|
||||
const totalAtividades = $derived(atividades?.length || 0);
|
||||
const totalLogins = $derived(logins?.length || 0);
|
||||
const loginsSucesso = $derived(logins?.filter(l => l.sucesso).length || 0);
|
||||
const loginsFalha = $derived(logins?.filter(l => !l.sucesso).length || 0);
|
||||
|
||||
// Funções de exportação
|
||||
async function exportarCSV() {
|
||||
exportando = true;
|
||||
try {
|
||||
const dadosParaExportar = abaAtiva === "atividades" ? atividades : logins;
|
||||
|
||||
if (!dadosParaExportar || dadosParaExportar.length === 0) {
|
||||
alert("Nenhum dado para exportar");
|
||||
return;
|
||||
}
|
||||
|
||||
let csvData: Record<string, string>[] = [];
|
||||
|
||||
if (abaAtiva === "atividades") {
|
||||
csvData = dadosParaExportar.map((atividade: any) => ({
|
||||
"Data/Hora": formatarData(atividade.timestamp),
|
||||
"Usuário": atividade.usuarioNome || "Sistema",
|
||||
"Matrícula": atividade.usuarioMatricula || "-",
|
||||
"Ação": getAcaoLabel(atividade.acao),
|
||||
"Recurso": atividade.recurso,
|
||||
"Detalhes": atividade.detalhes || "-"
|
||||
}));
|
||||
} else {
|
||||
csvData = dadosParaExportar.map((login: any) => {
|
||||
const usuarioInfo = [];
|
||||
if (login.usuarioNome) usuarioInfo.push(login.usuarioNome);
|
||||
if (login.usuarioEmail) usuarioInfo.push(login.usuarioEmail);
|
||||
if (!login.usuarioNome && !login.usuarioEmail && login.matriculaOuEmail) {
|
||||
usuarioInfo.push(login.matriculaOuEmail);
|
||||
}
|
||||
|
||||
return {
|
||||
"Data/Hora": formatarData(login.timestamp),
|
||||
"Usuário": login.usuarioNome || "-",
|
||||
"Email": login.usuarioEmail || login.matriculaOuEmail || "-",
|
||||
"Status": login.sucesso ? "Sucesso" : "Falhou",
|
||||
"Motivo Falha": login.motivoFalha || "-",
|
||||
"IP": login.ipAddress || "-",
|
||||
"Localização": formatarLocalizacao(login),
|
||||
"Dispositivo": login.device || "-",
|
||||
"Navegador": login.browser || "-",
|
||||
"Sistema": login.sistema || "-"
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const csv = Papa.unparse(csvData);
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`auditoria-${abaAtiva}-${format(new Date(), "yyyy-MM-dd-HHmm", { locale: ptBR })}.csv`
|
||||
);
|
||||
link.style.visibility = "hidden";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error("Erro ao exportar CSV:", error);
|
||||
alert("Erro ao exportar CSV. Tente novamente.");
|
||||
} finally {
|
||||
exportando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportarPDF() {
|
||||
exportando = true;
|
||||
try {
|
||||
const dadosParaExportar = abaAtiva === "atividades" ? atividades : logins;
|
||||
|
||||
if (!dadosParaExportar || dadosParaExportar.length === 0) {
|
||||
alert("Nenhum dado para exportar");
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = new jsPDF();
|
||||
|
||||
// Adicionar logo no canto superior esquerdo
|
||||
let yPos = 20;
|
||||
try {
|
||||
const logoImg = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
setTimeout(() => reject(new Error('Timeout loading logo')), 3000);
|
||||
img.src = logoGovPE;
|
||||
});
|
||||
|
||||
const logoWidth = 25;
|
||||
const aspectRatio = logoImg.height / logoImg.width;
|
||||
const logoHeight = logoWidth * aspectRatio;
|
||||
|
||||
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
||||
yPos = 10 + logoHeight + 10;
|
||||
} catch (err) {
|
||||
console.warn('Erro ao carregar logo:', err);
|
||||
yPos = 20;
|
||||
}
|
||||
|
||||
// Título
|
||||
doc.setFontSize(20);
|
||||
doc.setTextColor(102, 126, 234);
|
||||
const titulo = abaAtiva === "atividades"
|
||||
? "Relatório de Atividades do Sistema"
|
||||
: "Relatório de Histórico de Logins";
|
||||
doc.text(titulo, 14, yPos);
|
||||
|
||||
// Informações gerais
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(0, 0, 0);
|
||||
yPos += 8;
|
||||
doc.text(
|
||||
`Gerado em: ${format(new Date(), "dd/MM/yyyy HH:mm", { locale: ptBR })}`,
|
||||
14,
|
||||
yPos
|
||||
);
|
||||
yPos += 6;
|
||||
doc.text(`Total de registros: ${dadosParaExportar.length}`, 14, yPos);
|
||||
yPos += 10;
|
||||
|
||||
if (abaAtiva === "atividades") {
|
||||
// Tabela de atividades
|
||||
const atividadesData = dadosParaExportar.map((atividade: any) => [
|
||||
formatarData(atividade.timestamp),
|
||||
atividade.usuarioNome || "Sistema",
|
||||
getAcaoLabel(atividade.acao),
|
||||
atividade.recurso,
|
||||
(atividade.detalhes || "-").substring(0, 50)
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPos,
|
||||
head: [["Data/Hora", "Usuário", "Ação", "Recurso", "Detalhes"]],
|
||||
body: atividadesData,
|
||||
theme: "striped",
|
||||
headStyles: { fillColor: [102, 126, 234] },
|
||||
styles: { fontSize: 8 }
|
||||
});
|
||||
} else {
|
||||
// Tabela de logins
|
||||
const loginsData = dadosParaExportar.map((login: any) => {
|
||||
const usuarioInfo = [];
|
||||
if (login.usuarioNome) usuarioInfo.push(login.usuarioNome);
|
||||
if (login.usuarioEmail) usuarioInfo.push(login.usuarioEmail);
|
||||
if (!login.usuarioNome && !login.usuarioEmail && login.matriculaOuEmail) {
|
||||
usuarioInfo.push(login.matriculaOuEmail);
|
||||
}
|
||||
const usuarioStr = usuarioInfo.length > 0 ? usuarioInfo.join(" / ") : "-";
|
||||
|
||||
return [
|
||||
formatarData(login.timestamp),
|
||||
usuarioStr,
|
||||
login.sucesso ? "Sucesso" : "Falhou",
|
||||
login.ipAddress || "-",
|
||||
formatarLocalizacao(login).substring(0, 30),
|
||||
login.device || "-",
|
||||
login.browser || "-"
|
||||
];
|
||||
});
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPos,
|
||||
head: [["Data/Hora", "Usuário/Email", "Status", "IP", "Localização", "Dispositivo", "Navegador"]],
|
||||
body: loginsData,
|
||||
theme: "striped",
|
||||
headStyles: { fillColor: [102, 126, 234] },
|
||||
styles: { fontSize: 7 }
|
||||
});
|
||||
}
|
||||
|
||||
// Footer
|
||||
const pageCount = doc.getNumberOfPages();
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
doc.setPage(i);
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(128, 128, 128);
|
||||
doc.text(
|
||||
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
||||
doc.internal.pageSize.getWidth() / 2,
|
||||
doc.internal.pageSize.getHeight() - 10,
|
||||
{ align: "center" }
|
||||
);
|
||||
}
|
||||
|
||||
doc.save(`auditoria-${abaAtiva}-${format(new Date(), "yyyy-MM-dd-HHmm", { locale: ptBR })}.pdf`);
|
||||
} catch (error) {
|
||||
console.error("Erro ao exportar PDF:", error);
|
||||
alert("Erro ao exportar PDF. Tente novamente.");
|
||||
} finally {
|
||||
exportando = false;
|
||||
}
|
||||
}
|
||||
|
||||
function limparFiltros() {
|
||||
filtroDataInicio = "";
|
||||
filtroDataFim = "";
|
||||
filtroUsuario = "";
|
||||
filtroStatus = "todos";
|
||||
filtroIP = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto px-4 py-6 max-w-7xl">
|
||||
@@ -163,13 +532,38 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-outline btn-primary btn-sm gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<button
|
||||
class="btn btn-outline btn-primary btn-sm gap-2"
|
||||
onclick={exportarCSV}
|
||||
disabled={exportando || (abaAtiva === "atividades" ? !atividades?.length : !logins?.length)}
|
||||
>
|
||||
{#if exportando}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{/if}
|
||||
Exportar CSV
|
||||
</button>
|
||||
<button class="btn btn-outline btn-secondary btn-sm gap-2">
|
||||
<button
|
||||
class="btn btn-outline btn-secondary btn-sm gap-2"
|
||||
onclick={exportarPDF}
|
||||
disabled={exportando || (abaAtiva === "atividades" ? !atividades?.length : !logins?.length)}
|
||||
>
|
||||
{#if exportando}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{/if}
|
||||
Exportar PDF
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline btn-secondary btn-sm gap-2"
|
||||
onclick={() => mostrarFiltros = !mostrarFiltros}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
@@ -177,6 +571,75 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtros Avançados -->
|
||||
{#if mostrarFiltros}
|
||||
<div class="divider my-4"></div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text font-medium">Data Início</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={filtroDataInicio}
|
||||
class="input input-bordered input-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text font-medium">Data Fim</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={filtroDataFim}
|
||||
class="input input-bordered input-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text font-medium">Usuário</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={filtroUsuario}
|
||||
placeholder="Nome ou matrícula"
|
||||
class="input input-bordered input-sm"
|
||||
/>
|
||||
</div>
|
||||
{#if abaAtiva === "logins"}
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text font-medium">Status</span>
|
||||
</label>
|
||||
<select bind:value={filtroStatus} class="select select-bordered select-sm">
|
||||
<option value="todos">Todos</option>
|
||||
<option value="sucesso">Sucesso</option>
|
||||
<option value="falha">Falha</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text font-medium">IP Address</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={filtroIP}
|
||||
placeholder="Ex: 192.168.1.1"
|
||||
class="input input-bordered input-sm"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="form-control flex items-end">
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={limparFiltros}
|
||||
>
|
||||
Limpar Filtros
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,17 +654,17 @@
|
||||
</svg>
|
||||
Atividades Recentes
|
||||
</h2>
|
||||
{#if atividades?.data}
|
||||
<div class="badge badge-outline badge-lg">{atividades.data.length} registro{atividades.data.length !== 1 ? 's' : ''}</div>
|
||||
{#if atividades}
|
||||
<div class="badge badge-outline badge-lg">{atividades.length} registro{atividades.length !== 1 ? 's' : ''}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !atividades?.data}
|
||||
{#if atividadesRaw === undefined}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
|
||||
<p class="text-base-content/60">Carregando atividades...</p>
|
||||
</div>
|
||||
{:else if atividades.data.length === 0}
|
||||
{:else if atividades.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-16 text-base-content/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
@@ -222,7 +685,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each atividades.data as atividade}
|
||||
{#each atividades as atividade}
|
||||
<tr class="hover transition-colors">
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -277,17 +740,17 @@
|
||||
</svg>
|
||||
Histórico de Logins
|
||||
</h2>
|
||||
{#if logins?.data}
|
||||
<div class="badge badge-outline badge-lg">{logins.data.length} registro{logins.data.length !== 1 ? 's' : ''}</div>
|
||||
{#if logins}
|
||||
<div class="badge badge-outline badge-lg">{logins.length} registro{logins.length !== 1 ? 's' : ''}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !logins?.data}
|
||||
{#if loginsRaw === undefined}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
|
||||
<p class="text-base-content/60">Carregando logins...</p>
|
||||
</div>
|
||||
{:else if logins.data.length === 0}
|
||||
{:else if logins.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-16 text-base-content/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
||||
@@ -304,13 +767,14 @@
|
||||
<th class="font-semibold">Usuário/Email</th>
|
||||
<th class="font-semibold">Status</th>
|
||||
<th class="font-semibold">IP</th>
|
||||
<th class="font-semibold">Localização</th>
|
||||
<th class="font-semibold">Dispositivo</th>
|
||||
<th class="font-semibold">Navegador</th>
|
||||
<th class="font-semibold">Sistema</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each logins.data as login}
|
||||
{#each logins as login}
|
||||
<tr class="hover transition-colors">
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -321,11 +785,27 @@
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium">{login.matriculaOuEmail}</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if login.usuarioNome}
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span class="text-sm font-semibold">{login.usuarioNome}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if login.usuarioEmail || login.matriculaOuEmail}
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 text-base-content/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="text-xs text-base-content/70 font-mono">
|
||||
{login.usuarioEmail || (login.matriculaOuEmail && typeof login.matriculaOuEmail === 'string' ? login.matriculaOuEmail : "-")}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-xs text-base-content/40">-</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -353,6 +833,17 @@
|
||||
<td>
|
||||
<span class="font-mono text-xs bg-base-200 px-2 py-1 rounded">{login.ipAddress || "-"}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2 max-w-xs">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/40 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span class="text-xs text-base-content/70 truncate" title={formatarLocalizacao(login)}>
|
||||
{formatarLocalizacao(login)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-xs text-base-content/70">{login.device || "-"}</div>
|
||||
</td>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,14 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Cibersecurity SGSE - Sistema de Gerenciamento de Secretaria • Wizcard TI</title>
|
||||
<title>Cibersecurity SGSE - Central de Segurança Cibernética</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="space-y-8 p-4 lg:p-8">
|
||||
<header class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-primary text-sm font-semibold tracking-widest uppercase">
|
||||
Cibersecurity • SGSE - Sistema de Gerenciamento de Secretaria
|
||||
Cibersecurity • SGSE - Central de Segurança Cibernética
|
||||
</p>
|
||||
<h1 class="text-base-content text-4xl font-black">Segurança Avançada</h1>
|
||||
<p class="text-base-content/70 max-w-3xl text-sm">
|
||||
|
||||
@@ -7,35 +7,14 @@
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
const configAtual = useQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||
|
||||
// Query condicional para configuração completa
|
||||
const configCompletaQuery = $derived(
|
||||
configAtual?.data?._id ? { configId: configAtual.data._id } : null
|
||||
);
|
||||
const configCompleta = useQuery(
|
||||
api.configuracaoJitsi.obterConfigJitsiCompleta,
|
||||
configCompletaQuery ? configCompletaQuery : 'skip'
|
||||
);
|
||||
|
||||
let domain = $state('');
|
||||
let appId = $state('sgse-app');
|
||||
let roomPrefix = $state('sgse');
|
||||
let useHttps = $state(false);
|
||||
let acceptSelfSignedCert = $state(false);
|
||||
|
||||
// Campos SSH/Docker
|
||||
let sshHost = $state('');
|
||||
let sshPort = $state(22);
|
||||
let sshUsername = $state('');
|
||||
let sshPassword = $state('');
|
||||
let sshKeyPath = $state('');
|
||||
let dockerComposePath = $state('');
|
||||
let jitsiConfigPath = $state('~/.jitsi-meet-cfg');
|
||||
|
||||
let mostrarConfigSSH = $state(false);
|
||||
let processando = $state(false);
|
||||
let testando = $state(false);
|
||||
let testandoSSH = $state(false);
|
||||
let aplicandoServidor = $state(false);
|
||||
let mensagem = $state<{ tipo: 'success' | 'error'; texto: string; detalhes?: string } | null>(
|
||||
null
|
||||
);
|
||||
@@ -65,19 +44,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Carregar configurações SSH/Docker
|
||||
$effect(() => {
|
||||
if (configCompleta?.data) {
|
||||
sshHost = configCompleta.data.sshHost || '';
|
||||
sshPort = configCompleta.data.sshPort || 22;
|
||||
sshUsername = configCompleta.data.sshUsername || '';
|
||||
sshPassword = ''; // Sempre limpar senha por segurança
|
||||
sshKeyPath = configCompleta.data.sshKeyPath || '';
|
||||
dockerComposePath = configCompleta.data.dockerComposePath || '';
|
||||
jitsiConfigPath = configCompleta.data.jitsiConfigPath || '~/.jitsi-meet-cfg';
|
||||
mostrarConfigSSH = !!(configCompleta.data.sshHost || configCompleta.data.sshUsername);
|
||||
}
|
||||
});
|
||||
|
||||
// Ativar HTTPS automaticamente se domínio contém porta 8443
|
||||
$effect(() => {
|
||||
@@ -117,15 +83,7 @@
|
||||
roomPrefix: roomPrefix.trim(),
|
||||
useHttps,
|
||||
acceptSelfSignedCert,
|
||||
configuradoPorId: currentUser.data._id as Id<'usuarios'>,
|
||||
// Configurações SSH/Docker (opcionais)
|
||||
sshHost: sshHost.trim() || undefined,
|
||||
sshPort: sshPort || undefined,
|
||||
sshUsername: sshUsername.trim() || undefined,
|
||||
sshPassword: sshPassword.trim() || undefined,
|
||||
sshKeyPath: sshKeyPath.trim() || undefined,
|
||||
dockerComposePath: dockerComposePath.trim() || undefined,
|
||||
jitsiConfigPath: jitsiConfigPath.trim() || undefined
|
||||
configuradoPorId: currentUser.data._id as Id<'usuarios'>
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
@@ -173,96 +131,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function testarConexaoSSH() {
|
||||
if (!sshHost?.trim() || !sshUsername?.trim()) {
|
||||
mostrarMensagem('error', 'Preencha Host e Usuário SSH antes de testar');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sshPassword?.trim() && !sshKeyPath?.trim()) {
|
||||
mostrarMensagem('error', 'Preencha a senha SSH ou o caminho da chave antes de testar');
|
||||
return;
|
||||
}
|
||||
|
||||
testandoSSH = true;
|
||||
try {
|
||||
const resultado = await client.action(api.actions.jitsiServer.testarConexaoSSH, {
|
||||
sshHost: sshHost.trim(),
|
||||
sshPort: sshPort || 22,
|
||||
sshUsername: sshUsername.trim(),
|
||||
sshPassword: sshPassword.trim() || undefined,
|
||||
sshKeyPath: sshKeyPath.trim() || undefined
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
mostrarMensagem('success', resultado.mensagem);
|
||||
} else {
|
||||
mostrarMensagem('error', `Erro ao testar SSH: ${resultado.erro}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('Erro ao testar SSH:', error);
|
||||
mostrarMensagem('error', errorMessage || 'Erro ao conectar via SSH');
|
||||
} finally {
|
||||
testandoSSH = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function aplicarConfiguracaoServidor() {
|
||||
if (!configAtual?.data?._id) {
|
||||
mostrarMensagem('error', 'Salve a configuração básica antes de aplicar no servidor');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sshHost?.trim() || !sshUsername?.trim()) {
|
||||
mostrarMensagem('error', 'Configure o acesso SSH antes de aplicar no servidor');
|
||||
return;
|
||||
}
|
||||
|
||||
// Senha SSH é necessária para aplicar (pode ser a armazenada ou uma nova)
|
||||
if (!sshPassword?.trim() && !sshKeyPath?.trim() && !configCompleta?.data?.sshPasswordHash) {
|
||||
mostrarMensagem(
|
||||
'error',
|
||||
'Forneça a senha SSH ou o caminho da chave para aplicar a configuração'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!confirm(
|
||||
'Deseja aplicar essas configurações no servidor Jitsi Docker? Os containers serão reiniciados.'
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
aplicandoServidor = true;
|
||||
try {
|
||||
const resultado = await client.action(api.actions.jitsiServer.aplicarConfiguracaoServidor, {
|
||||
configId: configAtual.data._id,
|
||||
sshPassword: sshPassword.trim() || undefined
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
mostrarMensagem('success', resultado.mensagem, resultado.detalhes);
|
||||
// Limpar senha após uso
|
||||
sshPassword = '';
|
||||
} else {
|
||||
mostrarMensagem('error', `Erro ao aplicar configuração: ${resultado.erro}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('Erro ao aplicar configuração:', error);
|
||||
mostrarMensagem('error', errorMessage || 'Erro ao aplicar configuração no servidor');
|
||||
} finally {
|
||||
aplicandoServidor = false;
|
||||
}
|
||||
}
|
||||
|
||||
const statusConfig = $derived(configAtual?.data?.ativo ? 'Configurado' : 'Não configurado');
|
||||
|
||||
const configuradoNoServidor = $derived(configCompleta?.data?.configuradoNoServidor ?? false);
|
||||
|
||||
const isLoading = $derived(configAtual === undefined);
|
||||
const hasError = $derived(configAtual === null && !isLoading);
|
||||
</script>
|
||||
@@ -478,218 +348,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configurações SSH/Docker -->
|
||||
<div class="divider"></div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h3 class="font-bold">Configuração SSH/Docker (Opcional)</h3>
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<span class="label-text text-sm">Configurar servidor via SSH</span>
|
||||
<input type="checkbox" bind:checked={mostrarConfigSSH} class="checkbox checkbox-sm" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if mostrarConfigSSH}
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<!-- SSH Host -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="ssh-host">
|
||||
<span class="label-text font-medium">Host SSH *</span>
|
||||
</label>
|
||||
<input
|
||||
id="ssh-host"
|
||||
type="text"
|
||||
bind:value={sshHost}
|
||||
placeholder="192.168.1.100 ou servidor.local"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Endereço do servidor Docker</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSH Port -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="ssh-port">
|
||||
<span class="label-text font-medium">Porta SSH</span>
|
||||
</label>
|
||||
<input
|
||||
id="ssh-port"
|
||||
type="number"
|
||||
bind:value={sshPort}
|
||||
placeholder="22"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- SSH Username -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="ssh-username">
|
||||
<span class="label-text font-medium">Usuário SSH *</span>
|
||||
</label>
|
||||
<input
|
||||
id="ssh-username"
|
||||
type="text"
|
||||
bind:value={sshUsername}
|
||||
placeholder="usuario"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- SSH Password ou Key Path -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="ssh-password">
|
||||
<span class="label-text font-medium">Senha SSH</span>
|
||||
</label>
|
||||
<input
|
||||
id="ssh-password"
|
||||
type="password"
|
||||
bind:value={sshPassword}
|
||||
placeholder="Deixe vazio para manter senha salva"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Ou use caminho da chave SSH abaixo</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSH Key Path -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="ssh-key-path">
|
||||
<span class="label-text font-medium">Caminho da Chave SSH</span>
|
||||
</label>
|
||||
<input
|
||||
id="ssh-key-path"
|
||||
type="text"
|
||||
bind:value={sshKeyPath}
|
||||
placeholder="/home/usuario/.ssh/id_rsa"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Caminho no servidor SSH para a chave privada</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Docker Compose Path -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="docker-compose-path">
|
||||
<span class="label-text font-medium">Caminho Docker Compose</span>
|
||||
</label>
|
||||
<input
|
||||
id="docker-compose-path"
|
||||
type="text"
|
||||
bind:value={dockerComposePath}
|
||||
placeholder="/home/usuario/jitsi-docker"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Diretório com docker-compose.yml</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jitsi Config Path -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="jitsi-config-path">
|
||||
<span class="label-text font-medium">Caminho Config Jitsi</span>
|
||||
</label>
|
||||
<input
|
||||
id="jitsi-config-path"
|
||||
type="text"
|
||||
bind:value={jitsiConfigPath}
|
||||
placeholder="~/.jitsi-meet-cfg"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Diretório de configurações do Jitsi</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Configuração Servidor -->
|
||||
{#if configuradoNoServidor}
|
||||
<div class="alert alert-success mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Configuração aplicada no servidor
|
||||
{#if configCompleta?.data?.configuradoNoServidorEm}
|
||||
em {new Date(configCompleta.data.configuradoNoServidorEm).toLocaleString('pt-BR')}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Botões SSH/Docker -->
|
||||
<div class="mt-4 flex gap-3">
|
||||
<button
|
||||
class="btn btn-outline btn-info"
|
||||
onclick={testarConexaoSSH}
|
||||
disabled={testandoSSH || processando || aplicandoServidor}
|
||||
>
|
||||
{#if testandoSSH}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
Testar SSH
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-success"
|
||||
onclick={aplicarConfiguracaoServidor}
|
||||
disabled={aplicandoServidor ||
|
||||
processando ||
|
||||
testando ||
|
||||
testandoSSH ||
|
||||
!configAtual?.data?._id}
|
||||
>
|
||||
{#if aplicandoServidor}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="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>
|
||||
{/if}
|
||||
Aplicar no Servidor Docker
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="card-actions mt-6 justify-end gap-3">
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
enviadoPor: Id<'usuarios'>;
|
||||
criadoEm: number;
|
||||
enviadoEm: number | undefined;
|
||||
erroDetalhes?: string;
|
||||
destinatarioInfo: Doc<'usuarios'> | null;
|
||||
templateInfo: Doc<'templatesMensagens'> | null;
|
||||
}
|
||||
@@ -55,10 +56,11 @@
|
||||
const emailIdsArray = $derived(
|
||||
Array.from(emailIdsRastreados).map((id) => id as Id<'notificacoesEmail'>)
|
||||
);
|
||||
// Usar função para evitar execução quando array está vazio
|
||||
const emailsStatusQuery = useQuery(api.email.buscarEmailsPorIds, () =>
|
||||
// Usar $derived para calcular argumentos da query condicionalmente
|
||||
const emailsStatusArgs = $derived(
|
||||
emailIdsArray.length === 0 ? 'skip' : { emailIds: emailIdsArray }
|
||||
);
|
||||
const emailsStatusQuery = useQuery(api.email.buscarEmailsPorIds, emailsStatusArgs);
|
||||
|
||||
// Queries para agendamentos
|
||||
const agendamentosEmailQuery = useQuery(api.email.listarAgendamentosEmail, {});
|
||||
@@ -95,6 +97,37 @@
|
||||
return [];
|
||||
});
|
||||
|
||||
const totalUsuarios = $derived(usuarios.length);
|
||||
const totalTemplates = $derived(templates.length);
|
||||
|
||||
function templateDisponivelParaCanal(
|
||||
template: Doc<'templatesMensagens'>,
|
||||
canalAtual: 'chat' | 'email' | 'ambos'
|
||||
): boolean {
|
||||
const categoria = template.categoria as 'email' | 'chat' | 'ambos' | undefined;
|
||||
|
||||
// Se não tiver categoria definida, considerar disponível para qualquer canal
|
||||
if (!categoria) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (canalAtual === 'ambos') {
|
||||
// No modo "ambos", aceitar templates marcados para qualquer canal ou ambos
|
||||
return categoria === 'chat' || categoria === 'email' || categoria === 'ambos';
|
||||
}
|
||||
|
||||
// Para canal específico, aceitar templates daquele canal ou "ambos"
|
||||
if (categoria === 'ambos') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return categoria === canalAtual;
|
||||
}
|
||||
|
||||
const templatesParaCanal = $derived.by(() =>
|
||||
templates.filter((t) => templateDisponivelParaCanal(t as Doc<'templatesMensagens'>, canal))
|
||||
);
|
||||
|
||||
// Estados de carregamento e erro
|
||||
const carregandoTemplates = $derived(templatesQuery === undefined || templatesQuery === null);
|
||||
const carregandoUsuarios = $derived(usuariosQuery === undefined || usuariosQuery === null);
|
||||
@@ -134,6 +167,9 @@
|
||||
let processando = $state(false);
|
||||
let criandoTemplates = $state(false);
|
||||
let progressoEnvio = $state({ total: 0, enviados: 0, falhas: 0 });
|
||||
|
||||
// Aba ativa
|
||||
let abaAtiva = $state<'enviar' | 'templates' | 'agendamentos'>('enviar');
|
||||
|
||||
// Estrutura de dados para logs de envio
|
||||
type StatusLog = 'sucesso' | 'erro' | 'fila' | 'info' | 'enviando';
|
||||
@@ -208,6 +244,29 @@
|
||||
return resultado;
|
||||
}
|
||||
|
||||
// Versão específica para CHAT: garante texto puro (sem HTML)
|
||||
function renderizarTemplateChatLocal(
|
||||
template: string,
|
||||
variaveis: Record<string, string>
|
||||
): string {
|
||||
const textoComVariaveis = renderizarTemplate(template, variaveis);
|
||||
// Remove todas as tags HTML (incluindo quebras de linha HTML)
|
||||
let textoPuro = textoComVariaveis.replace(/<[^>]*>/g, '');
|
||||
// Converte entidades HTML comuns para texto normal
|
||||
textoPuro = textoPuro
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&[a-zA-Z0-9#]+;/g, ''); // Remove outras entidades HTML
|
||||
// Normaliza espaços múltiplos (mas preserva quebras de linha reais)
|
||||
textoPuro = textoPuro.replace(/[ \t]+/g, ' ').replace(/[ \t]*\n[ \t]*/g, '\n');
|
||||
return textoPuro.trim();
|
||||
}
|
||||
|
||||
// Função para mostrar mensagens
|
||||
function mostrarMensagem(tipo: 'success' | 'error' | 'info', texto: string) {
|
||||
mensagem = { tipo, texto };
|
||||
@@ -681,9 +740,10 @@
|
||||
});
|
||||
|
||||
if (conversaId) {
|
||||
// Para chat, sempre remover HTML dos templates
|
||||
const mensagem =
|
||||
usarTemplate && templateSelecionado
|
||||
? renderizarTemplate(templateSelecionado.corpo, {
|
||||
? renderizarTemplateChatLocal(templateSelecionado.corpo, {
|
||||
nome: destinatario.nome,
|
||||
matricula: destinatario.matricula || ''
|
||||
})
|
||||
@@ -882,10 +942,10 @@
|
||||
});
|
||||
|
||||
if (conversaId) {
|
||||
// Renderizar template com variáveis do destinatário
|
||||
// Renderizar template com variáveis do destinatário (chat sempre em TEXTO PURO)
|
||||
const mensagem =
|
||||
usarTemplate && templateSelecionado
|
||||
? renderizarTemplate(templateSelecionado.corpo, {
|
||||
? renderizarTemplateChatLocal(templateSelecionado.corpo, {
|
||||
nome: destinatario.nome,
|
||||
matricula: destinatario.matricula || ''
|
||||
})
|
||||
@@ -1098,14 +1158,17 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto max-w-7xl px-4 py-6">
|
||||
<div class="container mx-auto max-w-7xl px-4 py-8">
|
||||
<div
|
||||
class="rounded-2xl bg-base-100/80 shadow-xl border border-base-200/60 p-6 lg:p-8 space-y-6 backdrop-blur"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-info/10 rounded-xl p-3">
|
||||
<div class="bg-gradient-to-br from-primary/15 via-info/10 to-secondary/10 rounded-2xl p-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-info h-8 w-8"
|
||||
class="text-primary h-9 w-9"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -1120,7 +1183,24 @@
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-base-content text-3xl font-bold">Notificações e Mensagens</h1>
|
||||
<p class="text-base-content/60 mt-1">Enviar notificações para usuários do sistema</p>
|
||||
<p class="text-base-content/60 mt-1 text-sm lg:text-base">
|
||||
Envie avisos importantes por <span class="font-semibold">chat</span> e
|
||||
<span class="font-semibold">email HTML padronizado</span> para os usuários do SGSE.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div class="bg-base-200/80 rounded-xl px-4 py-3 text-right">
|
||||
<div class="text-base-content/60 text-xs">Usuários alcançáveis</div>
|
||||
<div class="text-base-content text-lg font-semibold">
|
||||
{totalUsuarios}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-base-200/80 rounded-xl px-4 py-3 text-right">
|
||||
<div class="text-base-content/60 text-xs">Templates cadastrados</div>
|
||||
<div class="text-base-content text-lg font-semibold">
|
||||
{totalTemplates}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1173,6 +1253,36 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Abas de Navegação -->
|
||||
<div class="tabs tabs-boxed mb-6">
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
class:tab-active={abaAtiva === 'enviar'}
|
||||
onclick={() => (abaAtiva = 'enviar')}
|
||||
>
|
||||
Enviar Notificação
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
class:tab-active={abaAtiva === 'templates'}
|
||||
onclick={() => (abaAtiva = 'templates')}
|
||||
>
|
||||
Gerenciar Templates
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
class:tab-active={abaAtiva === 'agendamentos'}
|
||||
onclick={() => (abaAtiva = 'agendamentos')}
|
||||
>
|
||||
Agendamentos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo da Aba: Enviar Notificação -->
|
||||
{#if abaAtiva === 'enviar'}
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- Formulário -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
@@ -1281,8 +1391,8 @@
|
||||
<option value="">Selecione um template</option>
|
||||
{#if carregandoTemplates}
|
||||
<option disabled>Carregando templates...</option>
|
||||
{:else if templates.length > 0}
|
||||
{#each templates as template (template._id)}
|
||||
{:else if templatesParaCanal.length > 0}
|
||||
{#each templatesParaCanal as template (template._id)}
|
||||
<option value={template._id}>
|
||||
{template.nome}
|
||||
</option>
|
||||
@@ -1645,7 +1755,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Conteúdo da Aba: Templates -->
|
||||
{#if abaAtiva === 'templates'}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="card-title">Templates de Mensagens</h2>
|
||||
<a href="/ti/notificacoes/templates" class="btn btn-primary">
|
||||
Gerenciar Templates
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-base-content/60">
|
||||
Acesse a página de gerenciamento de templates para criar, editar e excluir templates de emails e
|
||||
mensagens.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Conteúdo da Aba: Agendamentos -->
|
||||
{#if abaAtiva === 'agendamentos'}
|
||||
<!-- Histórico de Agendamentos -->
|
||||
<div class="card bg-base-100 mt-6 shadow-xl">
|
||||
<div class="card-body">
|
||||
@@ -1864,6 +1995,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Info -->
|
||||
<div class="alert alert-warning mt-6">
|
||||
@@ -1882,6 +2014,7 @@
|
||||
</svg>
|
||||
<span>Para enviar emails, certifique-se de configurar o SMTP em Configurações de Email.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Novo Template -->
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { FunctionReference } from 'convex/server';
|
||||
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
const client = useConvexClient();
|
||||
const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>);
|
||||
|
||||
// Queries
|
||||
const templatesQuery = useQuery(api.templatesMensagens.listarTemplates, {});
|
||||
|
||||
const templates = $derived.by(() => {
|
||||
if (templatesQuery === undefined || templatesQuery === null) {
|
||||
return [];
|
||||
}
|
||||
if ('data' in templatesQuery && templatesQuery.data !== undefined) {
|
||||
return Array.isArray(templatesQuery.data) ? templatesQuery.data : [];
|
||||
}
|
||||
if (Array.isArray(templatesQuery)) {
|
||||
return templatesQuery;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// Estados
|
||||
let templateEditando = $state<Doc<'templatesMensagens'> | null>(null);
|
||||
let modalAberto = $state(false);
|
||||
let filtroCategoria = $state<'todos' | 'email' | 'chat' | 'ambos'>('todos');
|
||||
let buscaTexto = $state('');
|
||||
let processando = $state(false);
|
||||
let mensagem = $state<{ tipo: 'success' | 'error' | 'info'; texto: string } | null>(null);
|
||||
|
||||
// Filtrar templates
|
||||
const templatesFiltrados = $derived.by(() => {
|
||||
let filtrados = templates;
|
||||
|
||||
// Filtro por categoria
|
||||
if (filtroCategoria !== 'todos') {
|
||||
filtrados = filtrados.filter((t) => t.categoria === filtroCategoria);
|
||||
}
|
||||
|
||||
// Filtro por busca
|
||||
if (buscaTexto.trim()) {
|
||||
const busca = buscaTexto.toLowerCase();
|
||||
filtrados = filtrados.filter(
|
||||
(t) =>
|
||||
t.nome.toLowerCase().includes(busca) ||
|
||||
t.codigo.toLowerCase().includes(busca) ||
|
||||
t.titulo.toLowerCase().includes(busca)
|
||||
);
|
||||
}
|
||||
|
||||
return filtrados;
|
||||
});
|
||||
|
||||
function mostrarMensagem(tipo: 'success' | 'error' | 'info', texto: string) {
|
||||
mensagem = { tipo, texto };
|
||||
setTimeout(() => {
|
||||
mensagem = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function excluirTemplate(templateId: Id<'templatesMensagens'>) {
|
||||
if (!currentUser.data) return;
|
||||
if (!confirm('Tem certeza que deseja excluir este template?')) return;
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
const resultado = await client.mutation(api.templatesMensagens.excluirTemplate, {
|
||||
templateId,
|
||||
excluidoPorId: currentUser.data._id,
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
mostrarMensagem('success', 'Template excluído com sucesso!');
|
||||
} else {
|
||||
mostrarMensagem('error', resultado.erro || 'Erro ao excluir template');
|
||||
}
|
||||
} catch (error) {
|
||||
const erro = error instanceof Error ? error.message : 'Erro desconhecido';
|
||||
mostrarMensagem('error', `Erro ao excluir template: ${erro}`);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
function abrirModalEdicao(template: Doc<'templatesMensagens'>) {
|
||||
templateEditando = template;
|
||||
modalAberto = true;
|
||||
}
|
||||
|
||||
function fecharModal() {
|
||||
modalAberto = false;
|
||||
templateEditando = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto max-w-7xl px-4 py-8">
|
||||
<div
|
||||
class="rounded-2xl bg-base-100/80 shadow-xl border border-base-200/60 p-6 lg:p-8 space-y-6 backdrop-blur"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-gradient-to-br from-info/15 via-primary/10 to-secondary/10 rounded-2xl p-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-info h-9 w-9"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-base-content text-3xl font-bold">Gerenciar Templates</h1>
|
||||
<p class="text-base-content/60 mt-1 text-sm lg:text-base">
|
||||
Crie, edite e organize templates de <span class="font-semibold">chat</span> (texto puro) e
|
||||
<span class="font-semibold">email HTML padronizado</span> usados em todas as notificações do SGSE.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/ti/notificacoes" class="btn btn-outline gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Voltar para Notificações
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mensagens de Feedback -->
|
||||
{#if mensagem}
|
||||
<div
|
||||
class="alert shadow-lg"
|
||||
class:alert-success={mensagem.tipo === 'success'}
|
||||
class:alert-error={mensagem.tipo === 'error'}
|
||||
class:alert-info={mensagem.tipo === 'info'}
|
||||
>
|
||||
<span class="font-medium">{mensagem.texto}</span>
|
||||
<button type="button" class="btn btn-sm btn-circle btn-ghost" onclick={() => (mensagem = null)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filtros e Busca -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Buscar</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={buscaTexto}
|
||||
placeholder="Buscar por nome, código ou título..."
|
||||
class="input input-bordered"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Categoria</span>
|
||||
</label>
|
||||
<select bind:value={filtroCategoria} class="select select-bordered">
|
||||
<option value="todos">Todas</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="chat">Chat</option>
|
||||
<option value="ambos">Ambos</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de Templates -->
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="card-title">Templates ({templatesFiltrados.length})</h2>
|
||||
<a href={resolve('/ti/notificacoes/templates/novo')} class="btn btn-primary gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Novo Template
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if templatesFiltrados.length === 0}
|
||||
<div class="py-10 text-center">
|
||||
<p class="text-base-content/60">Nenhum template encontrado.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-zebra table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Código</th>
|
||||
<th>Nome</th>
|
||||
<th>Tipo</th>
|
||||
<th>Categoria</th>
|
||||
<th>Variáveis</th>
|
||||
<th>Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each templatesFiltrados as template (template._id)}
|
||||
<tr>
|
||||
<td>
|
||||
<code class="badge badge-ghost">{template.codigo}</code>
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-medium">{template.nome}</div>
|
||||
<div class="text-sm text-base-content/60">{template.titulo}</div>
|
||||
</td>
|
||||
<td>
|
||||
{#if template.tipo === 'sistema'}
|
||||
<span class="badge badge-info">Sistema</span>
|
||||
{:else}
|
||||
<span class="badge badge-success">Customizado</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if template.categoria}
|
||||
<span class="badge badge-outline">{template.categoria}</span>
|
||||
{:else}
|
||||
<span class="badge badge-ghost">-</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if template.variaveis && template.variaveis.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each template.variaveis.slice(0, 3) as variavel}
|
||||
<span class="badge badge-sm">{{variavel}}</span>
|
||||
{/each}
|
||||
{#if template.variaveis.length > 3}
|
||||
<span class="badge badge-sm">+{template.variaveis.length - 3}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-base-content/40">-</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
href={resolve(`/ti/notificacoes/templates/${template._id}`)}
|
||||
class="btn btn-sm btn-ghost"
|
||||
title="Editar"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{#if template.tipo === 'customizado'}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost text-error"
|
||||
onclick={() => excluirTemplate(template._id)}
|
||||
disabled={processando}
|
||||
title="Excluir"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { FunctionReference } from 'convex/server';
|
||||
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
const client = useConvexClient();
|
||||
const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>);
|
||||
|
||||
const templateIdParam = $derived($page.params.id ?? '');
|
||||
|
||||
// Query específica para buscar o template por ID
|
||||
const templateQuery = useQuery(
|
||||
api.templatesMensagens.obterTemplatePorId,
|
||||
() => {
|
||||
if (!templateIdParam) return 'skip';
|
||||
// Validar se o ID tem o formato correto do Convex
|
||||
if (typeof templateIdParam === 'string' && templateIdParam.length > 0) {
|
||||
return { templateId: templateIdParam as Id<'templatesMensagens'> };
|
||||
}
|
||||
return 'skip';
|
||||
}
|
||||
);
|
||||
|
||||
// Extrair template da query
|
||||
const template = $derived.by(() => {
|
||||
if (templateQuery === undefined || templateQuery === null) return null;
|
||||
// useQuery retorna os dados diretamente
|
||||
if (templateQuery && typeof templateQuery === 'object') {
|
||||
// Se tem propriedade data, usar ela
|
||||
if ('data' in templateQuery && templateQuery.data !== undefined && templateQuery.data !== null) {
|
||||
return templateQuery.data as Doc<'templatesMensagens'> | null;
|
||||
}
|
||||
// Caso contrário, assumir que é o próprio template
|
||||
if (!('data' in templateQuery)) {
|
||||
return templateQuery as Doc<'templatesMensagens'> | null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const carregandoTemplate = $derived(templateQuery === undefined || templateQuery === null);
|
||||
const erroTemplate = $derived.by(() => {
|
||||
if (templateQuery && typeof templateQuery === 'object' && 'error' in templateQuery) {
|
||||
return templateQuery.error as Error | string | null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
let nome = $state('');
|
||||
let titulo = $state('');
|
||||
let corpo = $state('');
|
||||
let categoria = $state<'email' | 'chat' | 'ambos'>('email');
|
||||
let variaveisTexto = $state('');
|
||||
let tagsTexto = $state('');
|
||||
let salvando = $state(false);
|
||||
let mensagem = $state<{
|
||||
tipo: 'success' | 'error' | 'info';
|
||||
texto: string;
|
||||
} | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (template) {
|
||||
nome = template.nome ?? '';
|
||||
titulo = template.titulo ?? '';
|
||||
corpo = template.corpo ?? '';
|
||||
categoria = (template.categoria as 'email' | 'chat' | 'ambos') ?? 'email';
|
||||
variaveisTexto = (template.variaveis ?? []).join(', ');
|
||||
tagsTexto = (template.tags ?? []).join(', ');
|
||||
}
|
||||
});
|
||||
|
||||
function mostrarMensagem(tipo: 'success' | 'error' | 'info', texto: string) {
|
||||
mensagem = { tipo, texto };
|
||||
setTimeout(() => {
|
||||
mensagem = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function parseLista(input: string): string[] {
|
||||
return input
|
||||
.split(/[;,\n]/)
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
}
|
||||
|
||||
async function salvar() {
|
||||
if (!template) {
|
||||
mostrarMensagem('error', 'Template não encontrado.');
|
||||
return;
|
||||
}
|
||||
if (!currentUser.data) {
|
||||
mostrarMensagem('error', 'Usuário não autenticado.');
|
||||
return;
|
||||
}
|
||||
if (!nome.trim() || !titulo.trim() || !corpo.trim()) {
|
||||
mostrarMensagem('error', 'Preencha todos os campos obrigatórios.');
|
||||
return;
|
||||
}
|
||||
|
||||
const variaveis = parseLista(variaveisTexto);
|
||||
const tags = parseLista(tagsTexto);
|
||||
|
||||
try {
|
||||
salvando = true;
|
||||
const resultado = await client.mutation(api.templatesMensagens.editarTemplate, {
|
||||
templateId: template._id,
|
||||
nome: nome.trim(),
|
||||
titulo: titulo.trim(),
|
||||
corpo: corpo.trim(),
|
||||
variaveis,
|
||||
categoria,
|
||||
tags,
|
||||
editadoPorId: currentUser.data._id
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
mostrarMensagem('success', 'Template atualizado com sucesso!');
|
||||
await goto(resolve('/ti/notificacoes/templates'));
|
||||
} else {
|
||||
mostrarMensagem('error', resultado.erro || 'Erro ao atualizar template.');
|
||||
}
|
||||
} catch (error) {
|
||||
const erro = error instanceof Error ? error.message : 'Erro desconhecido';
|
||||
mostrarMensagem('error', `Erro ao atualizar template: ${erro}`);
|
||||
} finally {
|
||||
salvando = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto max-w-4xl px-4 py-8">
|
||||
<div
|
||||
class="rounded-2xl bg-base-100/80 shadow-xl border border-base-200/60 p-6 lg:p-8 space-y-6 backdrop-blur"
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-gradient-to-br from-info/15 via-primary/10 to-secondary/10 rounded-2xl p-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-info h-9 w-9"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-base-content text-3xl font-bold">Editar Template</h1>
|
||||
<p class="text-base-content/60 mt-1 text-sm lg:text-base">
|
||||
Ajuste o texto base usado em <span class="font-semibold">chat</span> e na versão HTML de
|
||||
<span class="font-semibold">email</span>. Templates de sistema podem ter restrições de edição.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href={resolve('/ti/notificacoes/templates')} class="btn btn-outline"> Voltar </a>
|
||||
</div>
|
||||
|
||||
{#if carregandoTemplate}
|
||||
<div class="flex flex-col items-center justify-center gap-4 py-10">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<p class="text-base-content/60 text-sm">Carregando template...</p>
|
||||
</div>
|
||||
{:else if erroTemplate}
|
||||
<div class="alert alert-error">
|
||||
<span>Erro ao carregar template: {typeof erroTemplate === 'string' ? erroTemplate : erroTemplate?.message || 'Erro desconhecido'}</span>
|
||||
<a href={resolve('/ti/notificacoes/templates')} class="btn btn-sm btn-outline">
|
||||
Voltar para Templates
|
||||
</a>
|
||||
</div>
|
||||
{:else if !template}
|
||||
<div class="alert alert-error">
|
||||
<span>Template não encontrado. Verifique se o ID está correto.</span>
|
||||
<div class="text-xs mt-2 opacity-70">ID: {templateIdParam}</div>
|
||||
<a href={resolve('/ti/notificacoes/templates')} class="btn btn-sm btn-outline mt-2">
|
||||
Voltar para Templates
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
{#if mensagem}
|
||||
<div
|
||||
class="alert mb-4 shadow-lg"
|
||||
class:alert-success={mensagem.tipo === 'success'}
|
||||
class:alert-error={mensagem.tipo === 'error'}
|
||||
class:alert-info={mensagem.tipo === 'info'}
|
||||
>
|
||||
<span class="font-medium">{mensagem.texto}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
onclick={() => {
|
||||
mensagem = null;
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label" for="nome">
|
||||
<span class="label-text font-medium">Nome *</span>
|
||||
</label>
|
||||
<input
|
||||
id="nome"
|
||||
type="text"
|
||||
bind:value={nome}
|
||||
class="input input-bordered"
|
||||
maxlength="100"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="titulo">
|
||||
<span class="label-text font-medium">Título *</span>
|
||||
</label>
|
||||
<input
|
||||
id="titulo"
|
||||
type="text"
|
||||
bind:value={titulo}
|
||||
class="input input-bordered"
|
||||
maxlength="200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="categoria">
|
||||
<span class="label-text font-medium">Categoria</span>
|
||||
</label>
|
||||
<select id="categoria" bind:value={categoria} class="select select-bordered max-w-xs">
|
||||
<option value="email">Email</option>
|
||||
<option value="chat">Chat</option>
|
||||
<option value="ambos">Ambos</option>
|
||||
</select>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">
|
||||
<span class="font-semibold">Chat:</span> usa o texto puro do corpo. <span
|
||||
class="font-semibold">Email:</span
|
||||
> usa uma versão HTML profissional gerada automaticamente com cabeçalho e assinatura SGSE.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="corpo">
|
||||
<span class="label-text font-medium">Corpo da Mensagem *</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="corpo"
|
||||
bind:value={corpo}
|
||||
class="textarea textarea-bordered h-40"
|
||||
placeholder="Digite o conteúdo em TEXTO. Você pode usar {{variavel}} para valores dinâmicos."
|
||||
></textarea>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">
|
||||
Este texto será usado diretamente nas mensagens de
|
||||
<span class="font-semibold">chat</span>. Para
|
||||
<span class="font-semibold">email</span>, o sistema gera automaticamente um layout HTML
|
||||
padronizado com logo e assinatura.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label" for="variaveis">
|
||||
<span class="label-text font-medium">Variáveis (opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="variaveis"
|
||||
type="text"
|
||||
bind:value={variaveisTexto}
|
||||
class="input input-bordered"
|
||||
placeholder="nome, data, valor"
|
||||
/>
|
||||
<label class="label" for="variaveis">
|
||||
<span class="label-text-alt">
|
||||
Liste as variáveis que podem ser usadas no corpo (separadas por vírgula ou ponto e
|
||||
vírgula).
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="tags">
|
||||
<span class="label-text font-medium">Tags (opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="tags"
|
||||
type="text"
|
||||
bind:value={tagsTexto}
|
||||
class="input input-bordered"
|
||||
placeholder="avisos, chamados, rh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<a href={resolve('/ti/notificacoes/templates')} class="btn btn-ghost"> Cancelar </a>
|
||||
<button class="btn btn-primary" onclick={salvar} disabled={salvando}>
|
||||
{#if salvando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Salvando...
|
||||
{:else}
|
||||
Salvar Alterações
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,261 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { FunctionReference } from 'convex/server';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
const client = useConvexClient();
|
||||
const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>);
|
||||
|
||||
let codigo = $state('');
|
||||
let nome = $state('');
|
||||
let titulo = $state('');
|
||||
let corpo = $state('');
|
||||
let categoria = $state<'email' | 'chat' | 'ambos'>('email');
|
||||
let variaveisTexto = $state('');
|
||||
let tagsTexto = $state('');
|
||||
let criando = $state(false);
|
||||
let mensagem = $state<{
|
||||
tipo: 'success' | 'error' | 'info';
|
||||
texto: string;
|
||||
} | null>(null);
|
||||
|
||||
function mostrarMensagem(tipo: 'success' | 'error' | 'info', texto: string) {
|
||||
mensagem = { tipo, texto };
|
||||
setTimeout(() => {
|
||||
mensagem = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function parseLista(input: string): string[] {
|
||||
return input
|
||||
.split(/[;,\n]/)
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
}
|
||||
|
||||
async function salvar() {
|
||||
if (!currentUser.data) {
|
||||
mostrarMensagem('error', 'Usuário não autenticado.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!codigo.trim() || !nome.trim() || !titulo.trim() || !corpo.trim()) {
|
||||
mostrarMensagem('error', 'Preencha todos os campos obrigatórios.');
|
||||
return;
|
||||
}
|
||||
|
||||
const codigoNormalizado = codigo.trim().toUpperCase().replace(/\s+/g, '_');
|
||||
const variaveis = parseLista(variaveisTexto);
|
||||
const tags = parseLista(tagsTexto);
|
||||
|
||||
try {
|
||||
criando = true;
|
||||
const resultado = await client.mutation(api.templatesMensagens.criarTemplate, {
|
||||
codigo: codigoNormalizado,
|
||||
nome: nome.trim(),
|
||||
titulo: titulo.trim(),
|
||||
corpo: corpo.trim(),
|
||||
variaveis,
|
||||
categoria,
|
||||
tags,
|
||||
criadoPorId: currentUser.data._id as Id<'usuarios'>
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
mostrarMensagem('success', 'Template criado com sucesso!');
|
||||
await goto(resolve('/ti/notificacoes/templates'));
|
||||
} else {
|
||||
mostrarMensagem('error', resultado.erro || 'Erro ao criar template.');
|
||||
}
|
||||
} catch (error) {
|
||||
const erro = error instanceof Error ? error.message : 'Erro desconhecido';
|
||||
mostrarMensagem('error', `Erro ao criar template: ${erro}`);
|
||||
} finally {
|
||||
criando = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto max-w-4xl px-4 py-8">
|
||||
<div
|
||||
class="rounded-2xl bg-base-100/80 shadow-xl border border-base-200/60 p-6 lg:p-8 space-y-6 backdrop-blur"
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-gradient-to-br from-info/15 via-primary/10 to-secondary/10 rounded-2xl p-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-info h-9 w-9"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-base-content text-3xl font-bold">Novo Template</h1>
|
||||
<p class="text-base-content/60 mt-1 text-sm lg:text-base">
|
||||
Defina o texto base que será usado em <span class="font-semibold">chat</span> e na versão
|
||||
HTML de <span class="font-semibold">email</span> com o estilo padrão do SGSE.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href={resolve('/ti/notificacoes/templates')} class="btn btn-outline"> Voltar </a>
|
||||
</div>
|
||||
|
||||
{#if mensagem}
|
||||
<div
|
||||
class="alert mb-4 shadow-lg"
|
||||
class:alert-success={mensagem.tipo === 'success'}
|
||||
class:alert-error={mensagem.tipo === 'error'}
|
||||
class:alert-info={mensagem.tipo === 'info'}
|
||||
>
|
||||
<span class="font-medium">{mensagem.texto}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
onclick={() => {
|
||||
mensagem = null;
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label" for="codigo">
|
||||
<span class="label-text font-medium">Código *</span>
|
||||
<span class="label-text-alt">Ex: AVISO_IMPORTANTE</span>
|
||||
</label>
|
||||
<input
|
||||
id="codigo"
|
||||
type="text"
|
||||
bind:value={codigo}
|
||||
class="input input-bordered"
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="nome">
|
||||
<span class="label-text font-medium">Nome *</span>
|
||||
</label>
|
||||
<input
|
||||
id="nome"
|
||||
type="text"
|
||||
bind:value={nome}
|
||||
class="input input-bordered"
|
||||
maxlength="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="titulo">
|
||||
<span class="label-text font-medium">Título *</span>
|
||||
</label>
|
||||
<input
|
||||
id="titulo"
|
||||
type="text"
|
||||
bind:value={titulo}
|
||||
class="input input-bordered"
|
||||
maxlength="200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="categoria">
|
||||
<span class="label-text font-medium">Categoria</span>
|
||||
</label>
|
||||
<select id="categoria" bind:value={categoria} class="select select-bordered max-w-xs">
|
||||
<option value="email">Email</option>
|
||||
<option value="chat">Chat</option>
|
||||
<option value="ambos">Ambos</option>
|
||||
</select>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">
|
||||
<span class="font-semibold">Chat:</span> usa o texto puro do corpo. <span class="font-semibold"
|
||||
>Email:</span
|
||||
> usa uma versão HTML profissional gerada automaticamente com cabeçalho e assinatura SGSE.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="corpo">
|
||||
<span class="label-text font-medium">Corpo da Mensagem *</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="corpo"
|
||||
bind:value={corpo}
|
||||
class="textarea textarea-bordered h-40"
|
||||
placeholder="Digite o conteúdo em TEXTO. Você pode usar {{variavel}} para valores dinâmicos."
|
||||
></textarea>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">
|
||||
Este texto será usado diretamente nas mensagens de <span class="font-semibold">chat</span>.
|
||||
Para <span class="font-semibold">email</span>, o sistema gera automaticamente um layout HTML
|
||||
padronizado com logo e assinatura.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label" for="variaveis">
|
||||
<span class="label-text font-medium">Variáveis (opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="variaveis"
|
||||
type="text"
|
||||
bind:value={variaveisTexto}
|
||||
class="input input-bordered"
|
||||
placeholder="nome, data, valor"
|
||||
/>
|
||||
<label class="label" for="variaveis">
|
||||
<span class="label-text-alt">
|
||||
Liste as variáveis que podem ser usadas no corpo (separadas por vírgula ou ponto e
|
||||
vírgula).
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="tags">
|
||||
<span class="label-text font-medium">Tags (opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="tags"
|
||||
type="text"
|
||||
bind:value={tagsTexto}
|
||||
class="input input-bordered"
|
||||
placeholder="avisos, chamados, rh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<a href={resolve('/ti/notificacoes/templates')} class="btn btn-ghost"> Cancelar </a>
|
||||
<button class="btn btn-primary" onclick={salvar} disabled={criando}>
|
||||
{#if criando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Salvando...
|
||||
{:else}
|
||||
Salvar Template
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user