Merge remote-tracking branch 'origin' into feat-pedidos

This commit is contained in:
2025-12-02 00:58:10 -03:00
38 changed files with 9633 additions and 3679 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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">

View File

@@ -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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/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 -->

View File

@@ -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>

View File

@@ -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 &#123;&#123;variavel&#125;&#125; 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>

View File

@@ -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 &#123;&#123;variavel&#125;&#125; 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>