feat: enhance scheduling and management of email notifications
- Added functionality to cancel scheduled email notifications, improving user control over their email management. - Implemented a query to list all scheduled emails for the current user, providing better visibility into upcoming notifications. - Enhanced the email schema to support scheduling features, including a timestamp for scheduled delivery. - Improved error handling and user feedback for email scheduling actions, ensuring a smoother user experience.
This commit is contained in:
@@ -9,8 +9,9 @@
|
|||||||
| "bell"
|
| "bell"
|
||||||
| "monitor"
|
| "monitor"
|
||||||
| "document"
|
| "document"
|
||||||
| "teams";
|
| "teams"
|
||||||
type PaletteKey = "primary" | "success" | "secondary" | "accent" | "info" | "error";
|
| "userPlus";
|
||||||
|
type PaletteKey = "primary" | "success" | "secondary" | "accent" | "info" | "error" | "warning";
|
||||||
|
|
||||||
type FeatureCard = {
|
type FeatureCard = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -96,6 +97,15 @@
|
|||||||
badgeSolid: "badge-error text-error-content",
|
badgeSolid: "badge-error text-error-content",
|
||||||
badgeOutline: "badge-outline border-error/30",
|
badgeOutline: "badge-outline border-error/30",
|
||||||
},
|
},
|
||||||
|
warning: {
|
||||||
|
cardBorder: "border-warning/25",
|
||||||
|
iconBg: "bg-warning/15",
|
||||||
|
iconRing: "ring-1 ring-warning/25",
|
||||||
|
iconColor: "text-warning",
|
||||||
|
button: "btn-warning",
|
||||||
|
badgeSolid: "badge-warning text-warning-content",
|
||||||
|
badgeOutline: "badge-outline border-warning/30",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconPaths = {
|
const iconPaths = {
|
||||||
@@ -162,6 +172,13 @@
|
|||||||
strokeLinejoin: "round",
|
strokeLinejoin: "round",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
userPlus: [
|
||||||
|
{
|
||||||
|
d: "M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z",
|
||||||
|
strokeLinecap: "round",
|
||||||
|
strokeLinejoin: "round",
|
||||||
|
},
|
||||||
|
],
|
||||||
} satisfies Record<FeatureIcon, IconPath[]>;
|
} satisfies Record<FeatureIcon, IconPath[]>;
|
||||||
|
|
||||||
const featureCards: Array<FeatureCard> = [
|
const featureCards: Array<FeatureCard> = [
|
||||||
@@ -210,6 +227,15 @@
|
|||||||
palette: "accent",
|
palette: "accent",
|
||||||
icon: "users",
|
icon: "users",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Solicitações de Acesso",
|
||||||
|
description:
|
||||||
|
"Gerencie e analise solicitações de acesso ao sistema. Aprove ou rejeite novas solicitações de forma eficiente.",
|
||||||
|
ctaLabel: "Gerenciar Solicitações",
|
||||||
|
href: "/ti/solicitacoes-acesso",
|
||||||
|
palette: "warning",
|
||||||
|
icon: "userPlus",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Gestão de Times",
|
title: "Gestão de Times",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -4,7 +4,44 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import { authStore } from "$lib/stores/auth.svelte";
|
import { authStore } from "$lib/stores/auth.svelte";
|
||||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
import type { Id, Doc } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||||
|
|
||||||
|
// Tipos para agendamentos
|
||||||
|
type TipoAgendamento = "email" | "chat";
|
||||||
|
type StatusAgendamento = "agendado" | "enviado" | "cancelado";
|
||||||
|
|
||||||
|
interface AgendamentoEmail {
|
||||||
|
_id: Id<"notificacoesEmail">;
|
||||||
|
_creationTime: number;
|
||||||
|
destinatario: string;
|
||||||
|
destinatarioId: Id<"usuarios"> | undefined;
|
||||||
|
assunto: string;
|
||||||
|
corpo: string;
|
||||||
|
templateId: Id<"templatesMensagens"> | undefined;
|
||||||
|
status: "pendente" | "enviando" | "enviado" | "falha";
|
||||||
|
agendadaPara: number | undefined;
|
||||||
|
enviadoPor: Id<"usuarios">;
|
||||||
|
criadoEm: number;
|
||||||
|
enviadoEm: number | undefined;
|
||||||
|
destinatarioInfo: Doc<"usuarios"> | null;
|
||||||
|
templateInfo: Doc<"templatesMensagens"> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgendamentoChat {
|
||||||
|
_id: Id<"mensagens">;
|
||||||
|
_creationTime: number;
|
||||||
|
conversaId: Id<"conversas">;
|
||||||
|
remetenteId: Id<"usuarios">;
|
||||||
|
conteudo: string;
|
||||||
|
agendadaPara: number | undefined;
|
||||||
|
enviadaEm: number;
|
||||||
|
conversaInfo: Doc<"conversas"> | null;
|
||||||
|
destinatarioInfo: Doc<"usuarios"> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Agendamento =
|
||||||
|
| { tipo: "email"; dados: AgendamentoEmail }
|
||||||
|
| { tipo: "chat"; dados: AgendamentoChat };
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
@@ -16,12 +53,20 @@
|
|||||||
let emailIdsRastreados = $state<Set<string>>(new Set());
|
let emailIdsRastreados = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
// Query para buscar status dos emails
|
// Query para buscar status dos emails
|
||||||
const emailIdsArray = $derived(Array.from(emailIdsRastreados));
|
const emailIdsArray = $derived(Array.from(emailIdsRastreados).map(id => id as Id<"notificacoesEmail">));
|
||||||
const emailsStatusQuery = useQuery(
|
const emailsStatusQuery = useQuery(
|
||||||
api.email.buscarEmailsPorIds,
|
api.email.buscarEmailsPorIds,
|
||||||
emailIdsArray.length > 0 ? { emailIds: emailIdsArray as any[] } : undefined
|
emailIdsArray.length > 0 ? { emailIds: emailIdsArray } : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Queries para agendamentos
|
||||||
|
const agendamentosEmailQuery = useQuery(api.email.listarAgendamentosEmail, {});
|
||||||
|
const agendamentosChatQuery = useQuery(api.chat.listarAgendamentosChat, {});
|
||||||
|
|
||||||
|
// Filtro de agendamentos
|
||||||
|
type FiltroAgendamento = "todos" | "agendados" | "enviados";
|
||||||
|
let filtroAgendamento = $state<FiltroAgendamento>("todos");
|
||||||
|
|
||||||
// Extrair dados das queries de forma robusta
|
// Extrair dados das queries de forma robusta
|
||||||
const templates = $derived.by(() => {
|
const templates = $derived.by(() => {
|
||||||
if (templatesQuery === undefined || templatesQuery === null) {
|
if (templatesQuery === undefined || templatesQuery === null) {
|
||||||
@@ -241,6 +286,151 @@
|
|||||||
emailIdsRastreados = new Set();
|
emailIdsRastreados = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extrair e processar agendamentos
|
||||||
|
const agendamentosEmail = $derived.by(() => {
|
||||||
|
if (!agendamentosEmailQuery || agendamentosEmailQuery === undefined) return [];
|
||||||
|
const dados = Array.isArray(agendamentosEmailQuery)
|
||||||
|
? agendamentosEmailQuery
|
||||||
|
: "data" in agendamentosEmailQuery && Array.isArray(agendamentosEmailQuery.data)
|
||||||
|
? agendamentosEmailQuery.data
|
||||||
|
: [];
|
||||||
|
return dados as AgendamentoEmail[];
|
||||||
|
});
|
||||||
|
|
||||||
|
const agendamentosChat = $derived.by(() => {
|
||||||
|
if (!agendamentosChatQuery || agendamentosChatQuery === undefined) return [];
|
||||||
|
const dados = Array.isArray(agendamentosChatQuery)
|
||||||
|
? agendamentosChatQuery
|
||||||
|
: "data" in agendamentosChatQuery && Array.isArray(agendamentosChatQuery.data)
|
||||||
|
? agendamentosChatQuery.data
|
||||||
|
: [];
|
||||||
|
return dados as AgendamentoChat[];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combinar e processar agendamentos
|
||||||
|
const todosAgendamentos = $derived.by(() => {
|
||||||
|
const agendamentos: Agendamento[] = [];
|
||||||
|
|
||||||
|
for (const email of agendamentosEmail) {
|
||||||
|
if (email.agendadaPara) {
|
||||||
|
agendamentos.push({
|
||||||
|
tipo: "email",
|
||||||
|
dados: email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const chat of agendamentosChat) {
|
||||||
|
if (chat.agendadaPara) {
|
||||||
|
agendamentos.push({
|
||||||
|
tipo: "chat",
|
||||||
|
dados: chat,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar: futuros primeiro (mais próximos primeiro), depois passados (mais recentes primeiro)
|
||||||
|
return agendamentos.sort((a, b) => {
|
||||||
|
const timestampA = a.tipo === "email" ? (a.dados.agendadaPara ?? 0) : (a.dados.agendadaPara ?? 0);
|
||||||
|
const timestampB = b.tipo === "email" ? (b.dados.agendadaPara ?? 0) : (b.dados.agendadaPara ?? 0);
|
||||||
|
const agora = Date.now();
|
||||||
|
|
||||||
|
const aFuturo = timestampA > agora;
|
||||||
|
const bFuturo = timestampB > agora;
|
||||||
|
|
||||||
|
// Futuros primeiro
|
||||||
|
if (aFuturo && !bFuturo) return -1;
|
||||||
|
if (!aFuturo && bFuturo) return 1;
|
||||||
|
|
||||||
|
// Dentro do mesmo grupo, ordenar por timestamp
|
||||||
|
if (aFuturo) {
|
||||||
|
// Futuros: mais próximos primeiro
|
||||||
|
return timestampA - timestampB;
|
||||||
|
} else {
|
||||||
|
// Passados: mais recentes primeiro
|
||||||
|
return timestampB - timestampA;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtrar agendamentos
|
||||||
|
const agendamentosFiltrados = $derived.by(() => {
|
||||||
|
if (filtroAgendamento === "todos") return todosAgendamentos;
|
||||||
|
|
||||||
|
return todosAgendamentos.filter(ag => {
|
||||||
|
const status = obterStatusAgendamento(ag);
|
||||||
|
if (filtroAgendamento === "agendados") return status === "agendado";
|
||||||
|
if (filtroAgendamento === "enviados") return status === "enviado";
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Função para obter status do agendamento
|
||||||
|
function obterStatusAgendamento(agendamento: Agendamento): StatusAgendamento {
|
||||||
|
if (agendamento.tipo === "email") {
|
||||||
|
const email = agendamento.dados;
|
||||||
|
if (email.status === "enviado") return "enviado";
|
||||||
|
if (email.agendadaPara && email.agendadaPara <= Date.now()) return "enviado";
|
||||||
|
return "agendado";
|
||||||
|
} else {
|
||||||
|
const chat = agendamento.dados;
|
||||||
|
if (chat.agendadaPara && chat.agendadaPara <= Date.now()) return "enviado";
|
||||||
|
return "agendado";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para cancelar agendamento
|
||||||
|
async function cancelarAgendamento(agendamento: Agendamento) {
|
||||||
|
if (!confirm("Tem certeza que deseja cancelar este agendamento?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (agendamento.tipo === "email") {
|
||||||
|
const resultado = await client.mutation(api.email.cancelarAgendamentoEmail, {
|
||||||
|
emailId: agendamento.dados._id,
|
||||||
|
});
|
||||||
|
if (resultado.sucesso) {
|
||||||
|
mostrarMensagem("success", "Agendamento de email cancelado com sucesso!");
|
||||||
|
} else {
|
||||||
|
mostrarMensagem("error", resultado.erro || "Erro ao cancelar agendamento");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const resultado = await client.mutation(api.chat.cancelarMensagemAgendada, {
|
||||||
|
mensagemId: agendamento.dados._id,
|
||||||
|
});
|
||||||
|
if (resultado.sucesso) {
|
||||||
|
mostrarMensagem("success", "Agendamento de chat cancelado com sucesso!");
|
||||||
|
} else {
|
||||||
|
mostrarMensagem("error", resultado.erro || "Erro ao cancelar agendamento");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const erro = error instanceof Error ? error.message : "Erro desconhecido";
|
||||||
|
mostrarMensagem("error", `Erro ao cancelar agendamento: ${erro}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para obter nome do destinatário
|
||||||
|
function obterNomeDestinatario(agendamento: Agendamento): string {
|
||||||
|
if (agendamento.tipo === "email") {
|
||||||
|
return agendamento.dados.destinatarioInfo?.nome || agendamento.dados.destinatario || "Usuário";
|
||||||
|
} else {
|
||||||
|
return agendamento.dados.destinatarioInfo?.nome || "Usuário";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para formatar data/hora do agendamento
|
||||||
|
function formatarDataAgendamento(agendamento: Agendamento): string {
|
||||||
|
const timestamp = agendamento.tipo === "email"
|
||||||
|
? agendamento.dados.agendadaPara
|
||||||
|
: agendamento.dados.agendadaPara;
|
||||||
|
|
||||||
|
if (!timestamp) return "N/A";
|
||||||
|
|
||||||
|
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR });
|
||||||
|
}
|
||||||
|
|
||||||
// Função para formatar timestamp
|
// Função para formatar timestamp
|
||||||
function formatarTimestamp(timestamp: number): string {
|
function formatarTimestamp(timestamp: number): string {
|
||||||
return format(new Date(timestamp), "HH:mm:ss", { locale: ptBR });
|
return format(new Date(timestamp), "HH:mm:ss", { locale: ptBR });
|
||||||
@@ -278,9 +468,10 @@
|
|||||||
} else {
|
} else {
|
||||||
mostrarMensagem("error", "Erro ao criar templates padrão.");
|
mostrarMensagem("error", "Erro ao criar templates padrão.");
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
|
const erro = error instanceof Error ? error.message : "Erro desconhecido";
|
||||||
console.error("Erro ao criar templates:", error);
|
console.error("Erro ao criar templates:", error);
|
||||||
mostrarMensagem("error", "Erro ao criar templates: " + (error.message || "Erro desconhecido"));
|
mostrarMensagem("error", "Erro ao criar templates: " + erro);
|
||||||
} finally {
|
} finally {
|
||||||
criandoTemplates = false;
|
criandoTemplates = false;
|
||||||
}
|
}
|
||||||
@@ -351,9 +542,10 @@
|
|||||||
} else {
|
} else {
|
||||||
mostrarMensagem("error", "Erro ao criar template: " + (resultado.erro || "Erro desconhecido"));
|
mostrarMensagem("error", "Erro ao criar template: " + (resultado.erro || "Erro desconhecido"));
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
|
const erro = error instanceof Error ? error.message : "Erro desconhecido";
|
||||||
console.error("Erro ao criar template:", error);
|
console.error("Erro ao criar template:", error);
|
||||||
mostrarMensagem("error", "Erro ao criar template: " + (error.message || "Erro desconhecido"));
|
mostrarMensagem("error", "Erro ao criar template: " + erro);
|
||||||
} finally {
|
} finally {
|
||||||
criandoNovoTemplate = false;
|
criandoNovoTemplate = false;
|
||||||
}
|
}
|
||||||
@@ -436,7 +628,7 @@
|
|||||||
adicionarLog("chat", destinatario.nome, "enviando", "Criando/buscando conversa...");
|
adicionarLog("chat", destinatario.nome, "enviando", "Criando/buscando conversa...");
|
||||||
const conversaResult = await client.mutation(
|
const conversaResult = await client.mutation(
|
||||||
api.chat.criarOuBuscarConversaIndividual,
|
api.chat.criarOuBuscarConversaIndividual,
|
||||||
{ outroUsuarioId: destinatario._id as any }
|
{ outroUsuarioId: destinatario._id as Id<"usuarios"> }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (conversaResult.conversaId) {
|
if (conversaResult.conversaId) {
|
||||||
@@ -468,9 +660,10 @@
|
|||||||
} else {
|
} else {
|
||||||
adicionarLog("chat", destinatario.nome, "erro", "Falha ao criar/buscar conversa");
|
adicionarLog("chat", destinatario.nome, "erro", "Falha ao criar/buscar conversa");
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
|
const erro = error instanceof Error ? error.message : "Erro desconhecido";
|
||||||
console.error("Erro ao enviar chat:", error);
|
console.error("Erro ao enviar chat:", error);
|
||||||
adicionarLog("chat", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`);
|
adicionarLog("chat", destinatario.nome, "erro", `Erro: ${erro}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,7 +677,7 @@
|
|||||||
if (template) {
|
if (template) {
|
||||||
resultadoEmail = await client.mutation(api.email.enviarEmailComTemplate, {
|
resultadoEmail = await client.mutation(api.email.enviarEmailComTemplate, {
|
||||||
destinatario: destinatario.email,
|
destinatario: destinatario.email,
|
||||||
destinatarioId: destinatario._id as any,
|
destinatarioId: destinatario._id as Id<"usuarios">,
|
||||||
templateCodigo: template.codigo,
|
templateCodigo: template.codigo,
|
||||||
variaveis: {
|
variaveis: {
|
||||||
nome: destinatario.nome,
|
nome: destinatario.nome,
|
||||||
@@ -509,7 +702,7 @@
|
|||||||
} else {
|
} else {
|
||||||
resultadoEmail = await client.mutation(api.email.enfileirarEmail, {
|
resultadoEmail = await client.mutation(api.email.enfileirarEmail, {
|
||||||
destinatario: destinatario.email,
|
destinatario: destinatario.email,
|
||||||
destinatarioId: destinatario._id as any,
|
destinatarioId: destinatario._id as Id<"usuarios">,
|
||||||
assunto: "Notificação do Sistema",
|
assunto: "Notificação do Sistema",
|
||||||
corpo: mensagemPersonalizada,
|
corpo: mensagemPersonalizada,
|
||||||
enviadoPorId: authStore.usuario._id as Id<"usuarios">,
|
enviadoPorId: authStore.usuario._id as Id<"usuarios">,
|
||||||
@@ -526,9 +719,10 @@
|
|||||||
adicionarLog("email", destinatario.nome, "erro", "Falha ao enfileirar email");
|
adicionarLog("email", destinatario.nome, "erro", "Falha ao enfileirar email");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
|
const erro = error instanceof Error ? error.message : "Erro desconhecido";
|
||||||
console.error("Erro ao enviar email:", error);
|
console.error("Erro ao enviar email:", error);
|
||||||
adicionarLog("email", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`);
|
adicionarLog("email", destinatario.nome, "erro", `Erro: ${erro}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
adicionarLog("email", destinatario.nome, "erro", "Destinatário não possui email cadastrado");
|
adicionarLog("email", destinatario.nome, "erro", "Destinatário não possui email cadastrado");
|
||||||
@@ -577,7 +771,7 @@
|
|||||||
adicionarLog("chat", destinatario.nome, "enviando", "Processando...");
|
adicionarLog("chat", destinatario.nome, "enviando", "Processando...");
|
||||||
const conversaResult = await client.mutation(
|
const conversaResult = await client.mutation(
|
||||||
api.chat.criarOuBuscarConversaIndividual,
|
api.chat.criarOuBuscarConversaIndividual,
|
||||||
{ outroUsuarioId: destinatario._id as any }
|
{ outroUsuarioId: destinatario._id as Id<"usuarios"> }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (conversaResult.conversaId) {
|
if (conversaResult.conversaId) {
|
||||||
@@ -609,9 +803,10 @@
|
|||||||
adicionarLog("chat", destinatario.nome, "erro", "Falha ao criar/buscar conversa");
|
adicionarLog("chat", destinatario.nome, "erro", "Falha ao criar/buscar conversa");
|
||||||
falhasChat++;
|
falhasChat++;
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
|
const erro = error instanceof Error ? error.message : "Erro desconhecido";
|
||||||
console.error(`Erro ao enviar chat para ${destinatario.nome}:`, error);
|
console.error(`Erro ao enviar chat para ${destinatario.nome}:`, error);
|
||||||
adicionarLog("chat", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`);
|
adicionarLog("chat", destinatario.nome, "erro", `Erro: ${erro}`);
|
||||||
falhasChat++;
|
falhasChat++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -626,7 +821,7 @@
|
|||||||
if (template) {
|
if (template) {
|
||||||
const resultadoEmail = await client.mutation(api.email.enviarEmailComTemplate, {
|
const resultadoEmail = await client.mutation(api.email.enviarEmailComTemplate, {
|
||||||
destinatario: destinatario.email,
|
destinatario: destinatario.email,
|
||||||
destinatarioId: destinatario._id as any,
|
destinatarioId: destinatario._id as Id<"usuarios">,
|
||||||
templateCodigo: template.codigo,
|
templateCodigo: template.codigo,
|
||||||
variaveis: {
|
variaveis: {
|
||||||
nome: destinatario.nome,
|
nome: destinatario.nome,
|
||||||
@@ -654,7 +849,7 @@
|
|||||||
} else {
|
} else {
|
||||||
const resultadoEmail = await client.mutation(api.email.enfileirarEmail, {
|
const resultadoEmail = await client.mutation(api.email.enfileirarEmail, {
|
||||||
destinatario: destinatario.email,
|
destinatario: destinatario.email,
|
||||||
destinatarioId: destinatario._id as any,
|
destinatarioId: destinatario._id as Id<"usuarios">,
|
||||||
assunto: "Notificação do Sistema",
|
assunto: "Notificação do Sistema",
|
||||||
corpo: mensagemPersonalizada,
|
corpo: mensagemPersonalizada,
|
||||||
enviadoPorId: authStore.usuario._id as Id<"usuarios">,
|
enviadoPorId: authStore.usuario._id as Id<"usuarios">,
|
||||||
@@ -673,9 +868,10 @@
|
|||||||
falhasEmail++;
|
falhasEmail++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
|
const erro = error instanceof Error ? error.message : "Erro desconhecido";
|
||||||
console.error(`Erro ao enviar email para ${destinatario.nome}:`, error);
|
console.error(`Erro ao enviar email para ${destinatario.nome}:`, error);
|
||||||
adicionarLog("email", destinatario.nome, "erro", `Erro: ${error.message || "Erro desconhecido"}`);
|
adicionarLog("email", destinatario.nome, "erro", `Erro: ${erro}`);
|
||||||
falhasEmail++;
|
falhasEmail++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -723,10 +919,11 @@
|
|||||||
agendarEnvio = false;
|
agendarEnvio = false;
|
||||||
dataAgendamento = "";
|
dataAgendamento = "";
|
||||||
horaAgendamento = "";
|
horaAgendamento = "";
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
|
const erro = error instanceof Error ? error.message : "Erro desconhecido";
|
||||||
console.error("Erro ao enviar notificação:", error);
|
console.error("Erro ao enviar notificação:", error);
|
||||||
adicionarLog("email", "Sistema", "erro", `Erro geral: ${error.message || "Erro desconhecido"}`);
|
adicionarLog("email", "Sistema", "erro", `Erro geral: ${erro}`);
|
||||||
mostrarMensagem("error", "Erro ao enviar notificação: " + (error.message || "Erro desconhecido"));
|
mostrarMensagem("error", "Erro ao enviar notificação: " + erro);
|
||||||
} finally {
|
} finally {
|
||||||
processando = false;
|
processando = false;
|
||||||
progressoEnvio = { total: 0, enviados: 0, falhas: 0 };
|
progressoEnvio = { total: 0, enviados: 0, falhas: 0 };
|
||||||
@@ -1188,6 +1385,162 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Histórico de Agendamentos -->
|
||||||
|
<div class="card bg-base-100 shadow-xl mt-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 bg-secondary/10 rounded-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="card-title">Histórico de Agendamentos</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm {filtroAgendamento === 'todos' ? 'btn-primary' : 'btn-ghost'}"
|
||||||
|
onclick={() => filtroAgendamento = "todos"}
|
||||||
|
>
|
||||||
|
Todos
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm {filtroAgendamento === 'agendados' ? 'btn-primary' : 'btn-ghost'}"
|
||||||
|
onclick={() => filtroAgendamento = "agendados"}
|
||||||
|
>
|
||||||
|
Agendados
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm {filtroAgendamento === 'enviados' ? 'btn-primary' : 'btn-ghost'}"
|
||||||
|
onclick={() => filtroAgendamento = "enviados"}
|
||||||
|
>
|
||||||
|
Enviados
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if agendamentosFiltrados.length === 0}
|
||||||
|
<div class="text-center py-10">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<p class="font-medium text-base-content mb-2">Nenhum agendamento encontrado</p>
|
||||||
|
<p class="text-sm text-base-content/60">Os agendamentos aparecerão aqui quando você agendar envios.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Tabela de Agendamentos -->
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Destinatário</th>
|
||||||
|
<th>Data/Hora</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Template</th>
|
||||||
|
<th>Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each agendamentosFiltrados as agendamento}
|
||||||
|
{@const status = obterStatusAgendamento(agendamento)}
|
||||||
|
{@const nomeDestinatario = obterNomeDestinatario(agendamento)}
|
||||||
|
{@const dataFormatada = formatarDataAgendamento(agendamento)}
|
||||||
|
{@const podeCancelar = status === "agendado"}
|
||||||
|
{@const templateNome = agendamento.tipo === "email" && agendamento.dados.templateInfo
|
||||||
|
? agendamento.dados.templateInfo.nome
|
||||||
|
: agendamento.tipo === "email" && agendamento.dados.templateId
|
||||||
|
? "Template removido"
|
||||||
|
: "-"}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if agendamento.tipo === "email"}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-info" 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="badge badge-info badge-sm">Email</span>
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
<span class="badge badge-primary badge-sm">Chat</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="font-medium">{nomeDestinatario}</div>
|
||||||
|
{#if agendamento.tipo === "email"}
|
||||||
|
<div class="text-xs text-base-content/60">{agendamento.dados.destinatario}</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="font-medium">{dataFormatada}</div>
|
||||||
|
{#if podeCancelar}
|
||||||
|
{@const tempoRestante = agendamento.tipo === "email"
|
||||||
|
? (agendamento.dados.agendadaPara ?? 0) - Date.now()
|
||||||
|
: (agendamento.dados.agendadaPara ?? 0) - Date.now()}
|
||||||
|
{@const horasRestantes = Math.floor(tempoRestante / (1000 * 60 * 60))}
|
||||||
|
{@const minutosRestantes = Math.floor((tempoRestante % (1000 * 60 * 60)) / (1000 * 60))}
|
||||||
|
{#if horasRestantes < 1 && minutosRestantes < 60}
|
||||||
|
<div class="text-xs text-warning">Em {minutosRestantes} min</div>
|
||||||
|
{:else if horasRestantes < 24}
|
||||||
|
<div class="text-xs text-info">Em {horasRestantes}h {minutosRestantes}min</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if status === "agendado"}
|
||||||
|
<span class="badge badge-warning badge-sm">Agendado</span>
|
||||||
|
{:else if status === "enviado"}
|
||||||
|
<span class="badge badge-success badge-sm">Enviado</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge badge-error badge-sm">Cancelado</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if agendamento.tipo === "email"}
|
||||||
|
{#if agendamento.dados.templateInfo}
|
||||||
|
<div class="text-sm">{agendamento.dados.templateInfo.nome}</div>
|
||||||
|
{:else if agendamento.dados.templateId}
|
||||||
|
<div class="text-sm text-base-content/60 italic">Template removido</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-sm text-base-content/60">-</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="text-sm text-base-content/60">-</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if podeCancelar}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-error btn-outline"
|
||||||
|
onclick={() => cancelarAgendamento(agendamento)}
|
||||||
|
>
|
||||||
|
<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="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="text-sm text-base-content/60">-</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Info -->
|
<!-- Info -->
|
||||||
<div class="alert alert-warning mt-6">
|
<div class="alert alert-warning mt-6">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
|
||||||
|
|||||||
@@ -0,0 +1,693 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useQuery, useConvexClient } from "convex-svelte";
|
||||||
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
|
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||||
|
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||||
|
import StatsCard from "$lib/components/ti/StatsCard.svelte";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { ptBR } from "date-fns/locale";
|
||||||
|
|
||||||
|
type StatusSolicitacao = "pendente" | "aprovado" | "rejeitado";
|
||||||
|
|
||||||
|
type SolicitacaoAcesso = {
|
||||||
|
_id: Id<"solicitacoesAcesso">;
|
||||||
|
_creationTime: number;
|
||||||
|
nome: string;
|
||||||
|
matricula: string;
|
||||||
|
email: string;
|
||||||
|
telefone: string;
|
||||||
|
status: StatusSolicitacao;
|
||||||
|
dataSolicitacao: number;
|
||||||
|
dataResposta: number | null;
|
||||||
|
observacoes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FiltroStatus = "todos" | "pendente" | "aprovado" | "rejeitado";
|
||||||
|
|
||||||
|
type Mensagem = {
|
||||||
|
tipo: "success" | "error" | "info";
|
||||||
|
texto: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const solicitacoesQuery = useQuery(api.solicitacoesAcesso.getAll, {});
|
||||||
|
|
||||||
|
// Estados
|
||||||
|
let filtroStatus = $state<FiltroStatus>("todos");
|
||||||
|
let busca = $state("");
|
||||||
|
let solicitacaoSelecionada = $state<SolicitacaoAcesso | null>(null);
|
||||||
|
let modalDetalhesAberto = $state(false);
|
||||||
|
let modalAprovarAberto = $state(false);
|
||||||
|
let modalRejeitarAberto = $state(false);
|
||||||
|
let observacoes = $state("");
|
||||||
|
let mensagem = $state<Mensagem | null>(null);
|
||||||
|
let processando = $state(false);
|
||||||
|
|
||||||
|
// Extrair dados das solicitações
|
||||||
|
const solicitacoes = $derived.by(() => {
|
||||||
|
if (solicitacoesQuery === undefined || solicitacoesQuery === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("data" in solicitacoesQuery && solicitacoesQuery.data !== undefined) {
|
||||||
|
return Array.isArray(solicitacoesQuery.data) ? solicitacoesQuery.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(solicitacoesQuery)) {
|
||||||
|
return solicitacoesQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const carregando = $derived.by(() => {
|
||||||
|
return solicitacoesQuery === undefined || solicitacoesQuery === null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Estatísticas
|
||||||
|
const stats = $derived.by(() => {
|
||||||
|
if (carregando) return null;
|
||||||
|
|
||||||
|
const total = solicitacoes.length;
|
||||||
|
const pendentes = solicitacoes.filter(s => s.status === "pendente").length;
|
||||||
|
const aprovadas = solicitacoes.filter(s => s.status === "aprovado").length;
|
||||||
|
const rejeitadas = solicitacoes.filter(s => s.status === "rejeitado").length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
pendentes,
|
||||||
|
aprovadas,
|
||||||
|
rejeitadas
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtrar e buscar solicitações
|
||||||
|
const solicitacoesFiltradas = $derived.by(() => {
|
||||||
|
let resultado = solicitacoes;
|
||||||
|
|
||||||
|
// Filtrar por status
|
||||||
|
if (filtroStatus !== "todos") {
|
||||||
|
resultado = resultado.filter(s => s.status === filtroStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar por nome, matrícula ou email
|
||||||
|
if (busca.trim()) {
|
||||||
|
const termo = busca.toLowerCase().trim();
|
||||||
|
resultado = resultado.filter(s =>
|
||||||
|
s.nome.toLowerCase().includes(termo) ||
|
||||||
|
s.matricula.toLowerCase().includes(termo) ||
|
||||||
|
s.email.toLowerCase().includes(termo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar por data (mais recente primeiro)
|
||||||
|
return resultado.sort((a, b) => b.dataSolicitacao - a.dataSolicitacao);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Funções auxiliares
|
||||||
|
function formatarData(timestamp: number): string {
|
||||||
|
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatarDataRelativa(timestamp: number): string {
|
||||||
|
const agora = Date.now();
|
||||||
|
const diff = agora - timestamp;
|
||||||
|
const dias = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
const horas = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
const minutos = Math.floor(diff / (1000 * 60));
|
||||||
|
|
||||||
|
if (dias > 0) return `${dias} dia${dias > 1 ? 's' : ''} atrás`;
|
||||||
|
if (horas > 0) return `${horas} hora${horas > 1 ? 's' : ''} atrás`;
|
||||||
|
if (minutos > 0) return `${minutos} minuto${minutos > 1 ? 's' : ''} atrás`;
|
||||||
|
return "Agora";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadge(status: StatusSolicitacao): string {
|
||||||
|
switch (status) {
|
||||||
|
case "pendente":
|
||||||
|
return "badge-warning";
|
||||||
|
case "aprovado":
|
||||||
|
return "badge-success";
|
||||||
|
case "rejeitado":
|
||||||
|
return "badge-error";
|
||||||
|
default:
|
||||||
|
return "badge-neutral";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusTexto(status: StatusSolicitacao): string {
|
||||||
|
switch (status) {
|
||||||
|
case "pendente":
|
||||||
|
return "Pendente";
|
||||||
|
case "aprovado":
|
||||||
|
return "Aprovado";
|
||||||
|
case "rejeitado":
|
||||||
|
return "Rejeitado";
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Funções de modal
|
||||||
|
function abrirDetalhes(solicitacao: SolicitacaoAcesso) {
|
||||||
|
solicitacaoSelecionada = solicitacao;
|
||||||
|
modalDetalhesAberto = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fecharDetalhes() {
|
||||||
|
modalDetalhesAberto = false;
|
||||||
|
solicitacaoSelecionada = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirAprovar(solicitacao: SolicitacaoAcesso) {
|
||||||
|
solicitacaoSelecionada = solicitacao;
|
||||||
|
observacoes = "";
|
||||||
|
modalAprovarAberto = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fecharAprovar() {
|
||||||
|
modalAprovarAberto = false;
|
||||||
|
solicitacaoSelecionada = null;
|
||||||
|
observacoes = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirRejeitar(solicitacao: SolicitacaoAcesso) {
|
||||||
|
solicitacaoSelecionada = solicitacao;
|
||||||
|
observacoes = "";
|
||||||
|
modalRejeitarAberto = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fecharRejeitar() {
|
||||||
|
modalRejeitarAberto = false;
|
||||||
|
solicitacaoSelecionada = null;
|
||||||
|
observacoes = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Funções de ação
|
||||||
|
async function aprovarSolicitacao() {
|
||||||
|
if (!solicitacaoSelecionada) return;
|
||||||
|
|
||||||
|
processando = true;
|
||||||
|
mensagem = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.mutation(api.solicitacoesAcesso.aprovar, {
|
||||||
|
solicitacaoId: solicitacaoSelecionada._id,
|
||||||
|
observacoes: observacoes.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
mensagem = {
|
||||||
|
tipo: "success",
|
||||||
|
texto: "Solicitação aprovada com sucesso!",
|
||||||
|
};
|
||||||
|
|
||||||
|
fecharAprovar();
|
||||||
|
|
||||||
|
// Limpar mensagem após 3 segundos
|
||||||
|
setTimeout(() => {
|
||||||
|
mensagem = null;
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Erro ao aprovar solicitação";
|
||||||
|
mensagem = {
|
||||||
|
tipo: "error",
|
||||||
|
texto: errorMessage,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
processando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejeitarSolicitacao() {
|
||||||
|
if (!solicitacaoSelecionada) return;
|
||||||
|
|
||||||
|
processando = true;
|
||||||
|
mensagem = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.mutation(api.solicitacoesAcesso.rejeitar, {
|
||||||
|
solicitacaoId: solicitacaoSelecionada._id,
|
||||||
|
observacoes: observacoes.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
mensagem = {
|
||||||
|
tipo: "success",
|
||||||
|
texto: "Solicitação rejeitada com sucesso!",
|
||||||
|
};
|
||||||
|
|
||||||
|
fecharRejeitar();
|
||||||
|
|
||||||
|
// Limpar mensagem após 3 segundos
|
||||||
|
setTimeout(() => {
|
||||||
|
mensagem = null;
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Erro ao rejeitar solicitação";
|
||||||
|
mensagem = {
|
||||||
|
tipo: "error",
|
||||||
|
texto: errorMessage,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
processando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProtectedRoute allowedRoles={["ti_master", "admin", "ti_usuario"]} maxLevel={3}>
|
||||||
|
<div class="container mx-auto px-4 py-6 max-w-7xl">
|
||||||
|
<!-- Mensagem de Feedback -->
|
||||||
|
{#if mensagem}
|
||||||
|
<div class="alert alert-{mensagem.tipo} shadow-lg mb-6">
|
||||||
|
{#if mensagem.tipo === "success"}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" 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>
|
||||||
|
{:else if mensagem.tipo === "error"}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span>{mensagem.texto}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="p-3 bg-primary/10 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-base-content">Solicitações de Acesso</h1>
|
||||||
|
<p class="text-base-content/60 mt-1">Gerencie e analise solicitações de acesso ao sistema</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Estatísticas -->
|
||||||
|
{#if stats}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<StatsCard
|
||||||
|
title="Total de Solicitações"
|
||||||
|
value={stats.total}
|
||||||
|
icon='<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" />'
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatsCard
|
||||||
|
title="Pendentes"
|
||||||
|
value={stats.pendentes}
|
||||||
|
description="{stats.total > 0 ? ((stats.pendentes / stats.total) * 100).toFixed(1) + '% do total' : '0% do total'}"
|
||||||
|
icon='<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />'
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatsCard
|
||||||
|
title="Aprovadas"
|
||||||
|
value={stats.aprovadas}
|
||||||
|
description="{stats.total > 0 ? ((stats.aprovadas / stats.total) * 100).toFixed(1) + '% do total' : '0% do total'}"
|
||||||
|
icon='<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" />'
|
||||||
|
color="success"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatsCard
|
||||||
|
title="Rejeitadas"
|
||||||
|
value={stats.rejeitadas}
|
||||||
|
description="{stats.total > 0 ? ((stats.rejeitadas / stats.total) * 100).toFixed(1) + '% do total' : '0% do total'}"
|
||||||
|
icon='<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />'
|
||||||
|
color="error"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex justify-center items-center py-20">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Filtros e Busca -->
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Tabs de Status -->
|
||||||
|
<div class="tabs tabs-boxed mb-4 bg-base-200 p-2">
|
||||||
|
<button
|
||||||
|
class="tab {filtroStatus === 'todos' ? 'tab-active' : ''}"
|
||||||
|
onclick={() => filtroStatus = "todos"}
|
||||||
|
>
|
||||||
|
Todas
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab {filtroStatus === 'pendente' ? 'tab-active' : ''}"
|
||||||
|
onclick={() => filtroStatus = "pendente"}
|
||||||
|
>
|
||||||
|
Pendentes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab {filtroStatus === 'aprovado' ? 'tab-active' : ''}"
|
||||||
|
onclick={() => filtroStatus = "aprovado"}
|
||||||
|
>
|
||||||
|
Aprovadas
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab {filtroStatus === 'rejeitado' ? 'tab-active' : ''}"
|
||||||
|
onclick={() => filtroStatus = "rejeitado"}
|
||||||
|
>
|
||||||
|
Rejeitadas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Campo de Busca -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Buscar por nome, matrícula ou e-mail</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Digite para buscar..."
|
||||||
|
class="input input-bordered w-full pl-10"
|
||||||
|
bind:value={busca}
|
||||||
|
/>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-base-content/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista de Solicitações -->
|
||||||
|
{#if carregando}
|
||||||
|
<div class="flex justify-center items-center py-20">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
{:else if solicitacoesFiltradas.length === 0}
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body text-center py-20">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-base-content/30 mb-4" 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>
|
||||||
|
<h3 class="text-xl font-semibold text-base-content/70 mb-2">Nenhuma solicitação encontrada</h3>
|
||||||
|
<p class="text-base-content/50">
|
||||||
|
{#if busca.trim() || filtroStatus !== "todos"}
|
||||||
|
Tente ajustar os filtros ou a busca.
|
||||||
|
{:else}
|
||||||
|
Ainda não há solicitações de acesso cadastradas.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
{#each solicitacoesFiltradas as solicitacao}
|
||||||
|
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h3 class="text-xl font-bold text-base-content">{solicitacao.nome}</h3>
|
||||||
|
<span class="badge {getStatusBadge(solicitacao.status)} badge-lg">
|
||||||
|
{getStatusTexto(solicitacao.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-2 text-sm text-base-content/70">
|
||||||
|
<div class="flex items-center 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="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-semibold">Matrícula:</span>
|
||||||
|
<span>{solicitacao.matricula}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center 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="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="font-semibold">E-mail:</span>
|
||||||
|
<span>{solicitacao.email}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center 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="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-semibold">Telefone:</span>
|
||||||
|
<span>{solicitacao.telefone}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 text-xs text-base-content/50">
|
||||||
|
<span class="font-semibold">Solicitado em:</span> {formatarData(solicitacao.dataSolicitacao)} ({formatarDataRelativa(solicitacao.dataSolicitacao)})
|
||||||
|
{#if solicitacao.dataResposta}
|
||||||
|
<span class="ml-4 font-semibold">Processado em:</span> {formatarData(solicitacao.dataResposta)}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline btn-primary"
|
||||||
|
onclick={() => abrirDetalhes(solicitacao)}
|
||||||
|
>
|
||||||
|
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
Ver Detalhes
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if solicitacao.status === "pendente"}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-success"
|
||||||
|
onclick={() => abrirAprovar(solicitacao)}
|
||||||
|
>
|
||||||
|
<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="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
Aprovar
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-error"
|
||||||
|
onclick={() => abrirRejeitar(solicitacao)}
|
||||||
|
>
|
||||||
|
<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="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Rejeitar
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Modal de Detalhes -->
|
||||||
|
{#if modalDetalhesAberto && solicitacaoSelecionada}
|
||||||
|
<dialog class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<h3 class="font-bold text-2xl mb-4">Detalhes da Solicitação</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<span class="badge {getStatusBadge(solicitacaoSelecionada.status)} badge-lg">
|
||||||
|
{getStatusTexto(solicitacaoSelecionada.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Nome Completo</span>
|
||||||
|
</label>
|
||||||
|
<div class="input input-bordered">{solicitacaoSelecionada.nome}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Matrícula</span>
|
||||||
|
</label>
|
||||||
|
<div class="input input-bordered">{solicitacaoSelecionada.matricula}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">E-mail</span>
|
||||||
|
</label>
|
||||||
|
<div class="input input-bordered">{solicitacaoSelecionada.email}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Telefone</span>
|
||||||
|
</label>
|
||||||
|
<div class="input input-bordered">{solicitacaoSelecionada.telefone}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Data da Solicitação</span>
|
||||||
|
</label>
|
||||||
|
<div class="input input-bordered">
|
||||||
|
{formatarData(solicitacaoSelecionada.dataSolicitacao)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if solicitacaoSelecionada.dataResposta}
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Data de Processamento</span>
|
||||||
|
</label>
|
||||||
|
<div class="input input-bordered">
|
||||||
|
{formatarData(solicitacaoSelecionada.dataResposta)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if solicitacaoSelecionada.observacoes}
|
||||||
|
<div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Observações</span>
|
||||||
|
</label>
|
||||||
|
<div class="textarea textarea-bordered min-h-24">
|
||||||
|
{solicitacaoSelecionada.observacoes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn" onclick={fecharDetalhes}>Fechar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button onclick={fecharDetalhes}>fechar</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Modal de Aprovar -->
|
||||||
|
{#if modalAprovarAberto && solicitacaoSelecionada}
|
||||||
|
<dialog class="modal modal-open">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-2xl mb-4">Aprovar Solicitação</h3>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-base-content/70 mb-2">
|
||||||
|
Você está prestes a aprovar a solicitação de acesso de <strong>{solicitacaoSelecionada.nome}</strong>.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-base-content/60">
|
||||||
|
Após aprovar, o sistema permitirá que esta pessoa solicite acesso ao sistema.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Observações (opcional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered"
|
||||||
|
placeholder="Adicione observações sobre a aprovação..."
|
||||||
|
bind:value={observacoes}
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost"
|
||||||
|
onclick={fecharAprovar}
|
||||||
|
disabled={processando}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-success"
|
||||||
|
onclick={aprovarSolicitacao}
|
||||||
|
disabled={processando}
|
||||||
|
>
|
||||||
|
{#if processando}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Processando...
|
||||||
|
{: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>
|
||||||
|
Confirmar Aprovação
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button onclick={fecharAprovar}>fechar</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Modal de Rejeitar -->
|
||||||
|
{#if modalRejeitarAberto && solicitacaoSelecionada}
|
||||||
|
<dialog class="modal modal-open">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-2xl mb-4">Rejeitar Solicitação</h3>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-base-content/70 mb-2">
|
||||||
|
Você está prestes a rejeitar a solicitação de acesso de <strong>{solicitacaoSelecionada.nome}</strong>.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-base-content/60">
|
||||||
|
Esta ação não pode ser desfeita. Recomendamos adicionar um motivo para a rejeição.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Motivo da Rejeição (recomendado)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered"
|
||||||
|
placeholder="Descreva o motivo da rejeição..."
|
||||||
|
bind:value={observacoes}
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost"
|
||||||
|
onclick={fecharRejeitar}
|
||||||
|
disabled={processando}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-error"
|
||||||
|
onclick={rejeitarSolicitacao}
|
||||||
|
disabled={processando}
|
||||||
|
>
|
||||||
|
{#if processando}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Processando...
|
||||||
|
{: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="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Confirmar Rejeição
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button onclick={fecharRejeitar}>fechar</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
@@ -305,18 +305,32 @@ export const cancelarMensagemAgendada = mutation({
|
|||||||
args: {
|
args: {
|
||||||
mensagemId: v.id("mensagens"),
|
mensagemId: v.id("mensagens"),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
|
||||||
|
handler: async (ctx, args): Promise<{ sucesso: boolean; erro?: string }> => {
|
||||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||||
if (!usuarioAtual) throw new Error("Não autenticado");
|
if (!usuarioAtual) {
|
||||||
|
return { sucesso: false, erro: "Usuário não autenticado" };
|
||||||
|
}
|
||||||
|
|
||||||
const mensagem = await ctx.db.get(args.mensagemId);
|
const mensagem = await ctx.db.get(args.mensagemId);
|
||||||
if (!mensagem) throw new Error("Mensagem não encontrada");
|
if (!mensagem) {
|
||||||
|
return { sucesso: false, erro: "Mensagem não encontrada" };
|
||||||
|
}
|
||||||
|
|
||||||
if (mensagem.remetenteId !== usuarioAtual._id) {
|
if (mensagem.remetenteId !== usuarioAtual._id) {
|
||||||
throw new Error("Você só pode cancelar suas próprias mensagens");
|
return { sucesso: false, erro: "Você só pode cancelar suas próprias mensagens" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mensagem.agendadaPara) {
|
||||||
|
return { sucesso: false, erro: "Esta mensagem não está agendada" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mensagem.agendadaPara <= Date.now()) {
|
||||||
|
return { sucesso: false, erro: "A data de agendamento já passou" };
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.delete(args.mensagemId);
|
await ctx.db.delete(args.mensagemId);
|
||||||
return true;
|
return { sucesso: true };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -713,7 +727,7 @@ export const obterMensagensAgendadas = query({
|
|||||||
args: {
|
args: {
|
||||||
conversaId: v.id("conversas"),
|
conversaId: v.id("conversas"),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args): Promise<Doc<"mensagens">[]> => {
|
||||||
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||||
if (!usuarioAtual) return [];
|
if (!usuarioAtual) return [];
|
||||||
|
|
||||||
@@ -727,16 +741,73 @@ export const obterMensagensAgendadas = query({
|
|||||||
const minhasMensagensAgendadas = todasMensagens.filter(
|
const minhasMensagensAgendadas = todasMensagens.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.remetenteId === usuarioAtual._id &&
|
m.remetenteId === usuarioAtual._id &&
|
||||||
m.agendadaPara &&
|
m.agendadaPara !== undefined &&
|
||||||
m.agendadaPara > Date.now()
|
m.agendadaPara > Date.now()
|
||||||
);
|
);
|
||||||
|
|
||||||
return minhasMensagensAgendadas.sort(
|
return minhasMensagensAgendadas.sort(
|
||||||
(a, b) => (a.agendadaPara || 0) - (b.agendadaPara || 0)
|
(a, b) => (a.agendadaPara ?? 0) - (b.agendadaPara ?? 0)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listar todas as mensagens agendadas do usuário atual (para página de notificações)
|
||||||
|
*/
|
||||||
|
export const listarAgendamentosChat = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx): Promise<Array<Doc<"mensagens"> & { conversaInfo: Doc<"conversas"> | null; destinatarioInfo: Doc<"usuarios"> | null }>> => {
|
||||||
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||||
|
if (!usuarioAtual) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar todas as mensagens agendadas do usuário
|
||||||
|
const todasMensagens = await ctx.db
|
||||||
|
.query("mensagens")
|
||||||
|
.withIndex("by_remetente", (q) => q.eq("remetenteId", usuarioAtual._id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Filtrar apenas as que têm agendamento (passadas ou futuras)
|
||||||
|
const mensagensAgendadas = todasMensagens.filter(
|
||||||
|
(m) => m.agendadaPara !== undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enriquecer com informações da conversa e destinatário
|
||||||
|
const mensagensEnriquecidas = await Promise.all(
|
||||||
|
mensagensAgendadas.map(async (mensagem) => {
|
||||||
|
let conversaInfo: Doc<"conversas"> | null = null;
|
||||||
|
let destinatarioInfo: Doc<"usuarios"> | null = null;
|
||||||
|
|
||||||
|
conversaInfo = await ctx.db.get(mensagem.conversaId);
|
||||||
|
|
||||||
|
// Se for conversa individual, encontrar o outro participante
|
||||||
|
if (conversaInfo && conversaInfo.tipo === "individual") {
|
||||||
|
const outroParticipanteId = conversaInfo.participantes.find(
|
||||||
|
(p) => p !== usuarioAtual._id
|
||||||
|
);
|
||||||
|
if (outroParticipanteId) {
|
||||||
|
destinatarioInfo = await ctx.db.get(outroParticipanteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mensagem,
|
||||||
|
conversaInfo,
|
||||||
|
destinatarioInfo,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ordenar por data de agendamento (mais próximos primeiro)
|
||||||
|
return mensagensEnriquecidas.sort((a, b) => {
|
||||||
|
const dataA = a.agendadaPara ?? 0;
|
||||||
|
const dataB = b.agendadaPara ?? 0;
|
||||||
|
return dataA - dataB;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtém as notificações do usuário
|
* Obtém as notificações do usuário
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,10 +6,44 @@ import {
|
|||||||
internalMutation,
|
internalMutation,
|
||||||
internalQuery,
|
internalQuery,
|
||||||
} from "./_generated/server";
|
} from "./_generated/server";
|
||||||
import { Id } from "./_generated/dataModel";
|
import { Doc, Id } from "./_generated/dataModel";
|
||||||
|
import type { QueryCtx, MutationCtx } from "./_generated/server";
|
||||||
import { renderizarTemplate } from "./templatesMensagens";
|
import { renderizarTemplate } from "./templatesMensagens";
|
||||||
import { internal, api } from "./_generated/api";
|
import { internal, api } from "./_generated/api";
|
||||||
|
|
||||||
|
// ========== HELPERS ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function para obter usuário autenticado (Better Auth ou Sessão)
|
||||||
|
*/
|
||||||
|
async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx): Promise<Doc<"usuarios"> | null> {
|
||||||
|
// Tentar autenticação via Better Auth primeiro
|
||||||
|
const identity = await ctx.auth.getUserIdentity();
|
||||||
|
let usuarioAtual: Doc<"usuarios"> | null = null;
|
||||||
|
|
||||||
|
if (identity && identity.email) {
|
||||||
|
usuarioAtual = await ctx.db
|
||||||
|
.query("usuarios")
|
||||||
|
.withIndex("by_email", (q) => q.eq("email", identity.email!))
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se não encontrou via Better Auth, tentar via sessão mais recente
|
||||||
|
if (!usuarioAtual) {
|
||||||
|
const sessaoAtiva = await ctx.db
|
||||||
|
.query("sessoes")
|
||||||
|
.filter((q) => q.eq(q.field("ativo"), true))
|
||||||
|
.order("desc")
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (sessaoAtiva) {
|
||||||
|
usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return usuarioAtual;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enfileirar email para envio
|
* Enfileirar email para envio
|
||||||
*/
|
*/
|
||||||
@@ -187,7 +221,7 @@ export const reenviarEmail = mutation({
|
|||||||
emailId: v.id("notificacoesEmail"),
|
emailId: v.id("notificacoesEmail"),
|
||||||
},
|
},
|
||||||
returns: v.object({ sucesso: v.boolean() }),
|
returns: v.object({ sucesso: v.boolean() }),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args): Promise<{ sucesso: boolean }> => {
|
||||||
const email = await ctx.db.get(args.emailId);
|
const email = await ctx.db.get(args.emailId);
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return { sucesso: false };
|
return { sucesso: false };
|
||||||
@@ -205,6 +239,52 @@ export const reenviarEmail = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancelar agendamento de email
|
||||||
|
*/
|
||||||
|
export const cancelarAgendamentoEmail = mutation({
|
||||||
|
args: {
|
||||||
|
emailId: v.id("notificacoesEmail"),
|
||||||
|
},
|
||||||
|
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
|
||||||
|
handler: async (ctx, args): Promise<{ sucesso: boolean; erro?: string }> => {
|
||||||
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||||
|
if (!usuarioAtual) {
|
||||||
|
return { sucesso: false, erro: "Usuário não autenticado" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = await ctx.db.get(args.emailId);
|
||||||
|
if (!email) {
|
||||||
|
return { sucesso: false, erro: "Email não encontrado" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o email pertence ao usuário atual
|
||||||
|
if (email.enviadoPor !== usuarioAtual._id) {
|
||||||
|
return { sucesso: false, erro: "Você não tem permissão para cancelar este agendamento" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o email está agendado
|
||||||
|
if (!email.agendadaPara) {
|
||||||
|
return { sucesso: false, erro: "Este email não está agendado" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se ainda não foi enviado
|
||||||
|
if (email.status === "enviado") {
|
||||||
|
return { sucesso: false, erro: "Este email já foi enviado" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se já passou a data de agendamento
|
||||||
|
if (email.agendadaPara <= Date.now()) {
|
||||||
|
return { sucesso: false, erro: "A data de agendamento já passou" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletar o email agendado
|
||||||
|
await ctx.db.delete(args.emailId);
|
||||||
|
|
||||||
|
return { sucesso: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action para enviar email (será implementado com nodemailer)
|
* Action para enviar email (será implementado com nodemailer)
|
||||||
*
|
*
|
||||||
@@ -225,8 +305,8 @@ export const buscarEmailsPorIds = query({
|
|||||||
args: {
|
args: {
|
||||||
emailIds: v.array(v.id("notificacoesEmail")),
|
emailIds: v.array(v.id("notificacoesEmail")),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args): Promise<Doc<"notificacoesEmail">[]> => {
|
||||||
const emails = [];
|
const emails: Doc<"notificacoesEmail">[] = [];
|
||||||
for (const emailId of args.emailIds) {
|
for (const emailId of args.emailIds) {
|
||||||
const email = await ctx.db.get(emailId);
|
const email = await ctx.db.get(emailId);
|
||||||
if (email) {
|
if (email) {
|
||||||
@@ -237,6 +317,57 @@ export const buscarEmailsPorIds = query({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listar agendamentos de email do usuário atual
|
||||||
|
*/
|
||||||
|
export const listarAgendamentosEmail = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx): Promise<Array<Doc<"notificacoesEmail"> & { destinatarioInfo: Doc<"usuarios"> | null; templateInfo: Doc<"templatesMensagens"> | null }>> => {
|
||||||
|
const usuarioAtual = await getUsuarioAutenticado(ctx);
|
||||||
|
if (!usuarioAtual) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar todos os emails do usuário
|
||||||
|
const todosEmails = await ctx.db
|
||||||
|
.query("notificacoesEmail")
|
||||||
|
.withIndex("by_enviado_por", (q) => q.eq("enviadoPor", usuarioAtual._id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Filtrar apenas os que têm agendamento (passados ou futuros)
|
||||||
|
const emailsAgendados = todosEmails.filter((email) => email.agendadaPara !== undefined);
|
||||||
|
|
||||||
|
// Enriquecer com informações do destinatário e template
|
||||||
|
const emailsEnriquecidos = await Promise.all(
|
||||||
|
emailsAgendados.map(async (email) => {
|
||||||
|
let destinatarioInfo: Doc<"usuarios"> | null = null;
|
||||||
|
let templateInfo: Doc<"templatesMensagens"> | null = null;
|
||||||
|
|
||||||
|
if (email.destinatarioId) {
|
||||||
|
destinatarioInfo = await ctx.db.get(email.destinatarioId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email.templateId) {
|
||||||
|
templateInfo = await ctx.db.get(email.templateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...email,
|
||||||
|
destinatarioInfo,
|
||||||
|
templateInfo,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ordenar por data de agendamento (mais próximos primeiro)
|
||||||
|
return emailsEnriquecidos.sort((a, b) => {
|
||||||
|
const dataA = a.agendadaPara ?? 0;
|
||||||
|
const dataB = b.agendadaPara ?? 0;
|
||||||
|
return dataA - dataB;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const getActiveEmailConfig = internalQuery({
|
export const getActiveEmailConfig = internalQuery({
|
||||||
args: {},
|
args: {},
|
||||||
// Tipo inferido automaticamente pelo Convex
|
// Tipo inferido automaticamente pelo Convex
|
||||||
|
|||||||
@@ -501,11 +501,13 @@ export default defineSchema({
|
|||||||
enviadoPor: v.id("usuarios"),
|
enviadoPor: v.id("usuarios"),
|
||||||
criadoEm: v.number(),
|
criadoEm: v.number(),
|
||||||
enviadoEm: v.optional(v.number()),
|
enviadoEm: v.optional(v.number()),
|
||||||
|
agendadaPara: v.optional(v.number()), // timestamp para agendamento
|
||||||
})
|
})
|
||||||
.index("by_status", ["status"])
|
.index("by_status", ["status"])
|
||||||
.index("by_destinatario", ["destinatarioId"])
|
.index("by_destinatario", ["destinatarioId"])
|
||||||
.index("by_enviado_por", ["enviadoPor"])
|
.index("by_enviado_por", ["enviadoPor"])
|
||||||
.index("by_criado_em", ["criadoEm"]),
|
.index("by_criado_em", ["criadoEm"])
|
||||||
|
.index("by_agendamento", ["agendadaPara"]),
|
||||||
|
|
||||||
configuracaoAcesso: defineTable({
|
configuracaoAcesso: defineTable({
|
||||||
chave: v.string(), // "sessao_duracao", "max_tentativas_login", etc.
|
chave: v.string(), // "sessao_duracao", "max_tentativas_login", etc.
|
||||||
|
|||||||
Reference in New Issue
Block a user