Merge remote-tracking branch 'origin/master' into feat-ajuste-acesso

This commit is contained in:
2025-10-30 14:01:08 -03:00
76 changed files with 15420 additions and 1212 deletions

View File

@@ -0,0 +1,378 @@
<script lang="ts">
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
interface Periodo {
dataInicio: string;
dataFim: string;
diasCorridos: number;
}
interface Props {
solicitacao: any;
gestorId: string;
onSucesso?: () => void;
onCancelar?: () => void;
}
let { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
const client = useConvexClient();
let modoAjuste = $state(false);
let periodos = $state<Periodo[]>([]);
let motivoReprovacao = $state("");
let processando = $state(false);
let erro = $state("");
$effect(() => {
if (modoAjuste && periodos.length === 0) {
periodos = solicitacao.periodos.map((p: any) => ({...p}));
}
});
function calcularDias(periodo: Periodo) {
if (!periodo.dataInicio || !periodo.dataFim) {
periodo.diasCorridos = 0;
return;
}
const inicio = new Date(periodo.dataInicio);
const fim = new Date(periodo.dataFim);
if (fim < inicio) {
erro = "Data final não pode ser anterior à data inicial";
periodo.diasCorridos = 0;
return;
}
const diff = fim.getTime() - inicio.getTime();
const dias = Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
periodo.diasCorridos = dias;
erro = "";
}
async function aprovar() {
try {
processando = true;
erro = "";
await client.mutation(api.ferias.aprovar, {
solicitacaoId: solicitacao._id,
gestorId: gestorId as any,
});
if (onSucesso) onSucesso();
} catch (e: any) {
erro = e.message || "Erro ao aprovar solicitação";
} finally {
processando = false;
}
}
async function reprovar() {
if (!motivoReprovacao.trim()) {
erro = "Informe o motivo da reprovação";
return;
}
try {
processando = true;
erro = "";
await client.mutation(api.ferias.reprovar, {
solicitacaoId: solicitacao._id,
gestorId: gestorId as any,
motivoReprovacao,
});
if (onSucesso) onSucesso();
} catch (e: any) {
erro = e.message || "Erro ao reprovar solicitação";
} finally {
processando = false;
}
}
async function ajustarEAprovar() {
try {
processando = true;
erro = "";
await client.mutation(api.ferias.ajustarEAprovar, {
solicitacaoId: solicitacao._id,
gestorId: gestorId as any,
novosPeriodos: periodos,
});
if (onSucesso) onSucesso();
} catch (e: any) {
erro = e.message || "Erro ao ajustar e aprovar solicitação";
} finally {
processando = false;
}
}
function getStatusBadge(status: string) {
const badges: Record<string, string> = {
aguardando_aprovacao: "badge-warning",
aprovado: "badge-success",
reprovado: "badge-error",
data_ajustada_aprovada: "badge-info",
};
return badges[status] || "badge-neutral";
}
function getStatusTexto(status: string) {
const textos: Record<string, string> = {
aguardando_aprovacao: "Aguardando Aprovação",
aprovado: "Aprovado",
reprovado: "Reprovado",
data_ajustada_aprovada: "Data Ajustada e Aprovada",
};
return textos[status] || status;
}
function formatarData(data: number) {
return new Date(data).toLocaleString("pt-BR");
}
</script>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-start justify-between mb-4">
<div>
<h2 class="card-title text-2xl">
{solicitacao.funcionario?.nome || "Funcionário"}
</h2>
<p class="text-sm text-base-content/70 mt-1">
Ano de Referência: {solicitacao.anoReferencia}
</p>
</div>
<div class={`badge ${getStatusBadge(solicitacao.status)} badge-lg`}>
{getStatusTexto(solicitacao.status)}
</div>
</div>
<!-- Períodos Solicitados -->
<div class="mt-4">
<h3 class="font-semibold text-lg mb-3">Períodos Solicitados</h3>
<div class="space-y-2">
{#each solicitacao.periodos as periodo, index}
<div class="flex items-center gap-4 p-3 bg-base-200 rounded-lg">
<div class="badge badge-primary">{index + 1}</div>
<div class="flex-1 grid grid-cols-3 gap-2 text-sm">
<div>
<span class="text-base-content/70">Início:</span>
<span class="font-semibold ml-1">{new Date(periodo.dataInicio).toLocaleDateString("pt-BR")}</span>
</div>
<div>
<span class="text-base-content/70">Fim:</span>
<span class="font-semibold ml-1">{new Date(periodo.dataFim).toLocaleDateString("pt-BR")}</span>
</div>
<div>
<span class="text-base-content/70">Dias:</span>
<span class="font-bold ml-1 text-primary">{periodo.diasCorridos}</span>
</div>
</div>
</div>
{/each}
</div>
</div>
<!-- Observações -->
{#if solicitacao.observacao}
<div class="mt-4">
<h3 class="font-semibold mb-2">Observações</h3>
<div class="p-3 bg-base-200 rounded-lg text-sm">
{solicitacao.observacao}
</div>
</div>
{/if}
<!-- Histórico -->
{#if solicitacao.historicoAlteracoes && solicitacao.historicoAlteracoes.length > 0}
<div class="mt-4">
<h3 class="font-semibold mb-2">Histórico</h3>
<div class="space-y-1">
{#each solicitacao.historicoAlteracoes as hist}
<div class="text-xs text-base-content/70 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" 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>
<span>{formatarData(hist.data)}</span>
<span>-</span>
<span>{hist.acao}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Ações (apenas para status aguardando_aprovacao) -->
{#if solicitacao.status === "aguardando_aprovacao"}
<div class="divider mt-6"></div>
{#if !modoAjuste}
<!-- Modo Normal -->
<div class="space-y-4">
<div class="flex flex-wrap gap-2">
<button
type="button"
class="btn btn-success gap-2"
onclick={aprovar}
disabled={processando}
>
<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>
Aprovar
</button>
<button
type="button"
class="btn btn-info gap-2"
onclick={() => modoAjuste = true}
disabled={processando}
>
<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="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>
Ajustar Datas e Aprovar
</button>
</div>
<!-- Reprovar -->
<div class="card bg-base-200">
<div class="card-body p-4">
<h4 class="font-semibold text-sm mb-2">Reprovar Solicitação</h4>
<textarea
class="textarea textarea-bordered textarea-sm mb-2"
placeholder="Motivo da reprovação..."
bind:value={motivoReprovacao}
rows="2"
></textarea>
<button
type="button"
class="btn btn-error btn-sm gap-2"
onclick={reprovar}
disabled={processando || !motivoReprovacao.trim()}
>
<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>
Reprovar
</button>
</div>
</div>
</div>
{:else}
<!-- Modo Ajuste -->
<div class="space-y-4">
<h4 class="font-semibold">Ajustar Períodos</h4>
{#each periodos as periodo, index}
<div class="card bg-base-200">
<div class="card-body p-4">
<h5 class="font-medium mb-2">Período {index + 1}</h5>
<div class="grid grid-cols-3 gap-3">
<div class="form-control">
<label class="label" for={`ajuste-inicio-${index}`}>
<span class="label-text text-xs">Início</span>
</label>
<input
id={`ajuste-inicio-${index}`}
type="date"
class="input input-bordered input-sm"
bind:value={periodo.dataInicio}
onchange={() => calcularDias(periodo)}
/>
</div>
<div class="form-control">
<label class="label" for={`ajuste-fim-${index}`}>
<span class="label-text text-xs">Fim</span>
</label>
<input
id={`ajuste-fim-${index}`}
type="date"
class="input input-bordered input-sm"
bind:value={periodo.dataFim}
onchange={() => calcularDias(periodo)}
/>
</div>
<div class="form-control">
<label class="label" for={`ajuste-dias-${index}`}>
<span class="label-text text-xs">Dias</span>
</label>
<div id={`ajuste-dias-${index}`} class="flex items-center h-9 px-3 bg-base-300 rounded-lg" role="textbox" aria-readonly="true">
<span class="font-bold">{periodo.diasCorridos}</span>
</div>
</div>
</div>
</div>
</div>
{/each}
<div class="flex gap-2">
<button
type="button"
class="btn btn-ghost btn-sm"
onclick={() => modoAjuste = false}
disabled={processando}
>
Cancelar Ajuste
</button>
<button
type="button"
class="btn btn-primary btn-sm gap-2"
onclick={ajustarEAprovar}
disabled={processando}
>
<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>
Confirmar e Aprovar
</button>
</div>
</div>
{/if}
{/if}
<!-- Motivo Reprovação (se reprovado) -->
{#if solicitacao.status === "reprovado" && solicitacao.motivoReprovacao}
<div class="alert alert-error mt-4">
<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>
<div>
<div class="font-bold">Motivo da Reprovação:</div>
<div class="text-sm">{solicitacao.motivoReprovacao}</div>
</div>
</div>
{/if}
<!-- Erro -->
{#if erro}
<div class="alert alert-error mt-4">
<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>
<span>{erro}</span>
</div>
{/if}
<!-- Botão Fechar -->
{#if onCancelar}
<div class="card-actions justify-end mt-4">
<button
type="button"
class="btn btn-ghost"
onclick={onCancelar}
disabled={processando}
>
Fechar
</button>
</div>
{/if}
</div>
</div>

View File

@@ -142,7 +142,7 @@
</script>
<div class="form-control w-full">
<label class="label">
<label class="label" for="file-upload-input">
<span class="label-text font-medium flex items-center gap-2">
{label}
{#if helpUrl}
@@ -164,6 +164,7 @@
</label>
<input
id="file-upload-input"
type="file"
bind:this={fileInput}
onchange={handleFileSelect}
@@ -265,9 +266,9 @@
{/if}
{#if error}
<label class="label">
<div class="label">
<span class="label-text-alt text-error">{error}</span>
</label>
</div>
{/if}
</div>

View File

@@ -146,26 +146,53 @@
<!-- Header Fixo acima de tudo -->
<div class="navbar bg-gradient-to-r from-primary/30 via-primary/20 to-primary/30 backdrop-blur-sm shadow-lg border-b border-primary/10 px-6 lg:px-8 fixed top-0 left-0 right-0 z-50 min-h-24">
<div class="flex-none lg:hidden">
<label for="my-drawer-3" class="btn btn-square btn-ghost hover:bg-primary/20">
<label
for="my-drawer-3"
class="relative flex items-center justify-center w-14 h-14 rounded-2xl overflow-hidden cursor-pointer group transition-all duration-300 hover:scale-105"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
aria-label="Abrir menu"
>
<!-- Efeito de brilho no hover -->
<div class="absolute inset-0 bg-gradient-to-br from-white/0 to-white/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<!-- Ícone de menu hambúrguer -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block w-6 h-6 stroke-current"
class="w-7 h-7 text-white relative z-10 group-hover:scale-110 transition-transform duration-300"
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke-width="2.5"
d="M4 6h16M4 12h16M4 18h16"
stroke="currentColor"
></path>
</svg>
</label>
</div>
<div class="flex-1 flex items-center gap-4 lg:gap-6">
<!-- Logo MODERNO do Governo -->
<div class="avatar">
<div class="w-16 lg:w-20 rounded-lg shadow-md bg-white p-2">
<img src={logo} alt="Logo do Governo de PE" class="w-full h-full object-contain" />
<div
class="w-16 lg:w-20 rounded-2xl shadow-xl p-2 relative overflow-hidden group transition-all duration-300 hover:scale-105"
style="background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); border: 2px solid rgba(102, 126, 234, 0.1);"
>
<!-- Efeito de brilho no hover -->
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<!-- Logo -->
<img
src={logo}
alt="Logo do Governo de PE"
class="w-full h-full object-contain relative z-10 transition-transform duration-300 group-hover:scale-105"
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.08));"
/>
<!-- Brilho sutil no canto -->
<div class="absolute top-0 right-0 w-8 h-8 bg-gradient-to-br from-white/40 to-transparent rounded-bl-full opacity-70"></div>
</div>
</div>
<div class="flex flex-col">
@@ -185,27 +212,35 @@
<span class="text-xs text-base-content/60">{authStore.usuario?.role.nome}</span>
</div>
<div class="dropdown dropdown-end">
<!-- Botão de Perfil ULTRA MODERNO -->
<button
type="button"
tabindex="0"
class="btn btn-primary btn-circle btn-lg shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105"
class="relative flex items-center justify-center w-14 h-14 rounded-2xl overflow-hidden group transition-all duration-300 hover:scale-105"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
aria-label="Menu do usuário"
>
<!-- Efeito de brilho no hover -->
<div class="absolute inset-0 bg-gradient-to-br from-white/0 to-white/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<!-- Anel de pulso sutil -->
<div class="absolute inset-0 rounded-2xl" style="animation: pulse-ring-subtle 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;"></div>
<!-- Ícone de usuário moderno -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
fill="currentColor"
class="w-7 h-7 text-white relative z-10 group-hover:scale-110 transition-transform duration-300"
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
<path fill-rule="evenodd" d="M18.685 19.097A9.723 9.723 0 0021.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 003.065 7.097A9.716 9.716 0 0012 21.75a9.716 9.716 0 006.685-2.653zm-12.54-1.285A7.486 7.486 0 0112 15a7.486 7.486 0 015.855 2.812A8.224 8.224 0 0112 20.25a8.224 8.224 0 01-5.855-2.438zM15.75 9a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" clip-rule="evenodd" />
</svg>
<!-- Badge de status online -->
<div class="absolute top-1 right-1 w-3 h-3 bg-success rounded-full border-2 border-white shadow-lg" style="animation: pulse-dot 2s ease-in-out infinite;"></div>
</button>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-xl bg-base-100 rounded-box w-52 mt-4 border border-primary/20">
<li class="menu-title">
<span class="text-primary font-bold">{authStore.usuario?.nome}</span>
@@ -447,6 +482,8 @@
</div>
</div>
</div>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<form method="dialog" class="modal-backdrop" onclick={closeLoginModal}>
<button type="button">close</button>
</form>
@@ -550,3 +587,29 @@
<ChatWidget />
{/if}
<style>
/* Animação de pulso sutil para o anel do botão de perfil */
@keyframes pulse-ring-subtle {
0%, 100% {
opacity: 0.1;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(1.05);
}
}
/* Animação de pulso para o badge de status online */
@keyframes pulse-dot {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
}
</style>

View File

@@ -0,0 +1,304 @@
<script lang="ts">
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
interface Periodo {
id: string;
dataInicio: string;
dataFim: string;
diasCorridos: number;
}
interface Props {
funcionarioId: string;
onSucesso?: () => void;
onCancelar?: () => void;
}
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
const client = useConvexClient();
let anoReferencia = $state(new Date().getFullYear());
let observacao = $state("");
let periodos = $state<Periodo[]>([]);
let processando = $state(false);
let erro = $state("");
// Adicionar primeiro período ao carregar
$effect(() => {
if (periodos.length === 0) {
adicionarPeriodo();
}
});
function adicionarPeriodo() {
if (periodos.length >= 3) {
erro = "Máximo de 3 períodos permitidos";
return;
}
periodos.push({
id: crypto.randomUUID(),
dataInicio: "",
dataFim: "",
diasCorridos: 0,
});
}
function removerPeriodo(id: string) {
periodos = periodos.filter(p => p.id !== id);
}
function calcularDias(periodo: Periodo) {
if (!periodo.dataInicio || !periodo.dataFim) {
periodo.diasCorridos = 0;
return;
}
const inicio = new Date(periodo.dataInicio);
const fim = new Date(periodo.dataFim);
if (fim < inicio) {
erro = "Data final não pode ser anterior à data inicial";
periodo.diasCorridos = 0;
return;
}
const diff = fim.getTime() - inicio.getTime();
const dias = Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
periodo.diasCorridos = dias;
erro = "";
}
function validarPeriodos(): boolean {
if (periodos.length === 0) {
erro = "Adicione pelo menos 1 período";
return false;
}
for (const periodo of periodos) {
if (!periodo.dataInicio || !periodo.dataFim) {
erro = "Preencha as datas de todos os períodos";
return false;
}
if (periodo.diasCorridos <= 0) {
erro = "Todos os períodos devem ter pelo menos 1 dia";
return false;
}
}
// Verificar sobreposição de períodos
for (let i = 0; i < periodos.length; i++) {
for (let j = i + 1; j < periodos.length; j++) {
const p1Inicio = new Date(periodos[i].dataInicio);
const p1Fim = new Date(periodos[i].dataFim);
const p2Inicio = new Date(periodos[j].dataInicio);
const p2Fim = new Date(periodos[j].dataFim);
if (
(p2Inicio >= p1Inicio && p2Inicio <= p1Fim) ||
(p2Fim >= p1Inicio && p2Fim <= p1Fim) ||
(p1Inicio >= p2Inicio && p1Inicio <= p2Fim)
) {
erro = "Os períodos não podem se sobrepor";
return false;
}
}
}
return true;
}
async function enviarSolicitacao() {
if (!validarPeriodos()) return;
try {
processando = true;
erro = "";
await client.mutation(api.ferias.criarSolicitacao, {
funcionarioId: funcionarioId as any,
anoReferencia,
periodos: periodos.map(p => ({
dataInicio: p.dataInicio,
dataFim: p.dataFim,
diasCorridos: p.diasCorridos,
})),
observacao: observacao || undefined,
});
if (onSucesso) onSucesso();
} catch (e: any) {
erro = e.message || "Erro ao enviar solicitação";
} finally {
processando = false;
}
}
$effect(() => {
periodos.forEach(p => calcularDias(p));
});
</script>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Solicitar Férias
</h2>
<!-- Ano de Referência -->
<div class="form-control">
<label class="label" for="ano-referencia">
<span class="label-text font-semibold">Ano de Referência</span>
</label>
<input
id="ano-referencia"
type="number"
class="input input-bordered w-full max-w-xs"
bind:value={anoReferencia}
min={new Date().getFullYear()}
max={new Date().getFullYear() + 2}
/>
</div>
<!-- Períodos -->
<div class="mt-6">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-lg">Períodos ({periodos.length}/3)</h3>
{#if periodos.length < 3}
<button
type="button"
class="btn btn-sm btn-primary gap-2"
onclick={adicionarPeriodo}
>
<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="M12 4v16m8-8H4" />
</svg>
Adicionar Período
</button>
{/if}
</div>
<div class="space-y-4">
{#each periodos as periodo, index}
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-4">
<div class="flex items-center justify-between mb-3">
<h4 class="font-medium">Período {index + 1}</h4>
{#if periodos.length > 1}
<button
type="button"
class="btn btn-xs btn-error btn-square"
aria-label="Remover período"
onclick={() => removerPeriodo(periodo.id)}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" 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>
</button>
{/if}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label" for={`inicio-${periodo.id}`}>
<span class="label-text">Data Início</span>
</label>
<input
id={`inicio-${periodo.id}`}
type="date"
class="input input-bordered input-sm"
bind:value={periodo.dataInicio}
onchange={() => calcularDias(periodo)}
/>
</div>
<div class="form-control">
<label class="label" for={`fim-${periodo.id}`}>
<span class="label-text">Data Fim</span>
</label>
<input
id={`fim-${periodo.id}`}
type="date"
class="input input-bordered input-sm"
bind:value={periodo.dataFim}
onchange={() => calcularDias(periodo)}
/>
</div>
<div class="form-control">
<label class="label" for={`dias-${periodo.id}`}>
<span class="label-text">Dias Corridos</span>
</label>
<div id={`dias-${periodo.id}`} class="flex items-center h-9 px-3 bg-base-300 rounded-lg" role="textbox" aria-readonly="true">
<span class="font-bold text-lg">{periodo.diasCorridos}</span>
<span class="ml-1 text-sm">dias</span>
</div>
</div>
</div>
</div>
</div>
{/each}
</div>
</div>
<!-- Observações -->
<div class="form-control mt-6">
<label class="label" for="observacao">
<span class="label-text font-semibold">Observações (opcional)</span>
</label>
<textarea
id="observacao"
class="textarea textarea-bordered h-24"
placeholder="Adicione observações sobre sua solicitação..."
bind:value={observacao}
></textarea>
</div>
<!-- Erro -->
{#if erro}
<div class="alert alert-error mt-4">
<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>
<span>{erro}</span>
</div>
{/if}
<!-- Ações -->
<div class="card-actions justify-end mt-6">
{#if onCancelar}
<button
type="button"
class="btn btn-ghost"
onclick={onCancelar}
disabled={processando}
>
Cancelar
</button>
{/if}
<button
type="button"
class="btn btn-primary gap-2"
onclick={enviarSolicitacao}
disabled={processando}
>
{#if processando}
<span class="loading loading-spinner loading-sm"></span>
Enviando...
{: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>
Enviar Solicitação
{/if}
</button>
</div>
</div>
</div>

View File

@@ -17,11 +17,20 @@
let searchQuery = $state("");
// Debug: monitorar carregamento de dados
$effect(() => {
console.log("📊 [ChatList] Usuários carregados:", usuarios?.data?.length || 0);
console.log("👤 [ChatList] Meu perfil:", meuPerfil?.data?.nome || "Carregando...");
console.log("📋 [ChatList] Lista completa:", usuarios?.data);
});
const usuariosFiltrados = $derived.by(() => {
if (!usuarios?.data || !Array.isArray(usuarios.data) || !meuPerfil?.data) return [];
const meuId = meuPerfil.data._id;
// Filtrar o próprio usuário da lista
let listaFiltrada = usuarios.data.filter((u: any) => u._id !== meuPerfil.data._id);
let listaFiltrada = usuarios.data.filter((u: any) => u._id !== meuId);
// Aplicar busca por nome/email/matrícula
if (searchQuery.trim()) {
@@ -56,18 +65,41 @@
}
}
let processando = $state(false);
async function handleClickUsuario(usuario: any) {
if (processando) {
console.log("⏳ Já está processando uma ação, aguarde...");
return;
}
try {
processando = true;
console.log("🔄 Clicou no usuário:", usuario.nome, "ID:", usuario._id);
// Criar ou buscar conversa individual com este usuário
console.log("📞 Chamando mutation criarOuBuscarConversaIndividual...");
const conversaId = await client.mutation(api.chat.criarOuBuscarConversaIndividual, {
outroUsuarioId: usuario._id,
});
console.log("✅ Conversa criada/encontrada. ID:", conversaId);
// Abrir a conversa
console.log("📂 Abrindo conversa...");
abrirConversa(conversaId as any);
console.log("✅ Conversa aberta com sucesso!");
} catch (error) {
console.error("Erro ao abrir conversa:", error);
alert("Erro ao abrir conversa");
console.error("Erro ao abrir conversa:", error);
console.error("Detalhes do erro:", {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
usuario: usuario,
});
alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`);
} finally {
processando = false;
}
}
@@ -123,8 +155,9 @@
{#each usuariosFiltrados as usuario (usuario._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 transition-colors flex items-center gap-3"
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 transition-colors flex items-center gap-3 {processando ? 'opacity-50 cursor-wait' : 'cursor-pointer'}"
onclick={() => handleClickUsuario(usuario)}
disabled={processando}
>
<!-- Avatar -->
<div class="relative flex-shrink-0">

View File

@@ -19,23 +19,24 @@
let isMinimized = $state(false);
let activeConversation = $state<string | null>(null);
// Posição do widget (arrastável)
let position = $state({ x: 0, y: 0 });
let isDragging = $state(false);
let dragStart = $state({ x: 0, y: 0 });
let isAnimating = $state(false);
// Sincronizar com stores
$effect(() => {
isOpen = $chatAberto;
console.log("ChatWidget - isOpen:", isOpen);
});
$effect(() => {
isMinimized = $chatMinimizado;
console.log("ChatWidget - isMinimized:", isMinimized);
});
$effect(() => {
activeConversation = $conversaAtiva;
});
// Debug inicial
console.log("ChatWidget montado - isOpen:", isOpen, "isMinimized:", isMinimized);
function handleToggle() {
if (isOpen && !isMinimized) {
@@ -56,125 +57,279 @@
function handleMaximize() {
maximizarChat();
}
// Funcionalidade de arrastar
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0) return; // Apenas botão esquerdo
isDragging = true;
dragStart = {
x: e.clientX - position.x,
y: e.clientY - position.y,
};
document.body.classList.add('dragging');
e.preventDefault();
}
function handleMouseMove(e: MouseEvent) {
if (!isDragging) return;
const newX = e.clientX - dragStart.x;
const newY = e.clientY - dragStart.y;
// Dimensões do widget
const widgetWidth = isOpen && !isMinimized ? 440 : 72;
const widgetHeight = isOpen && !isMinimized ? 680 : 72;
// Limites da tela com margem de segurança
const minX = -(widgetWidth - 100); // Permitir até 100px visíveis
const maxX = window.innerWidth - 100; // Manter 100px dentro da tela
const minY = -(widgetHeight - 100);
const maxY = window.innerHeight - 100;
position = {
x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(minY, Math.min(newY, maxY)),
};
}
function handleMouseUp() {
if (isDragging) {
isDragging = false;
document.body.classList.remove('dragging');
// Garantir que está dentro dos limites ao soltar
ajustarPosicao();
}
}
function ajustarPosicao() {
isAnimating = true;
// Dimensões do widget
const widgetWidth = isOpen && !isMinimized ? 440 : 72;
const widgetHeight = isOpen && !isMinimized ? 680 : 72;
// Verificar se está fora dos limites
let newX = position.x;
let newY = position.y;
// Ajustar X
if (newX < -(widgetWidth - 100)) {
newX = -(widgetWidth - 100);
} else if (newX > window.innerWidth - 100) {
newX = window.innerWidth - 100;
}
// Ajustar Y
if (newY < -(widgetHeight - 100)) {
newY = -(widgetHeight - 100);
} else if (newY > window.innerHeight - 100) {
newY = window.innerHeight - 100;
}
position = { x: newX, y: newY };
setTimeout(() => {
isAnimating = false;
}, 300);
}
// Event listeners globais
if (typeof window !== 'undefined') {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
</script>
<!-- Botão flutuante (quando fechado ou minimizado) -->
<!-- Botão flutuante MODERNO E ARRASTÁVEL -->
{#if !isOpen || isMinimized}
<button
type="button"
class="fixed btn btn-circle btn-lg shadow-2xl hover:shadow-primary/40 hover:scale-110 transition-all duration-500 group relative border-0 bg-gradient-to-br from-primary via-primary to-primary/80"
style="z-index: 99999 !important; width: 4.5rem; height: 4.5rem; bottom: 1.5rem !important; right: 1.5rem !important; position: fixed !important;"
class="fixed group relative border-0 backdrop-blur-xl"
style="
z-index: 99999 !important;
width: 4.5rem;
height: 4.5rem;
bottom: {position.y === 0 ? '1.5rem' : `${window.innerHeight - position.y - 72}px`};
right: {position.x === 0 ? '1.5rem' : `${window.innerWidth - position.x - 72}px`};
position: fixed !important;
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
box-shadow:
0 20px 60px -10px rgba(102, 126, 234, 0.5),
0 10px 30px -5px rgba(118, 75, 162, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
border-radius: 50%;
cursor: {isDragging ? 'grabbing' : 'grab'};
transform: {isDragging ? 'scale(1.05)' : 'scale(1)'};
transition: {isAnimating ? 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)' : 'transform 0.2s, box-shadow 0.3s'};
"
onclick={handleToggle}
onmousedown={handleMouseDown}
aria-label="Abrir chat"
>
<!-- Anel pulsante interno -->
<div class="absolute inset-0 rounded-full bg-white/10 group-hover:scale-95 transition-transform duration-500"></div>
<!-- Anel de brilho rotativo -->
<div class="absolute inset-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-500"
style="background: conic-gradient(from 0deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%); animation: rotate 3s linear infinite;">
</div>
<!-- Ícone de chat premium -->
<!-- Ondas de pulso -->
<div class="absolute inset-0 rounded-full" style="animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;"></div>
<!-- Ícone de chat moderno com efeito 3D -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
fill="none"
stroke="currentColor"
class="w-9 h-9 relative z-10 text-white group-hover:scale-110 transition-all duration-500"
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-7 h-7 relative z-10 text-white group-hover:scale-110 transition-all duration-500"
style="filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375m-13.5 3.01c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.184-4.183a1.14 1.14 0 0 1 .778-.332 48.294 48.294 0 0 0 5.83-.498c1.585-.233 2.708-1.626 2.708-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
/>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<circle cx="9" cy="10" r="1" fill="currentColor"/>
<circle cx="12" cy="10" r="1" fill="currentColor"/>
<circle cx="15" cy="10" r="1" fill="currentColor"/>
</svg>
<!-- Badge premium com animação -->
{#if count && count > 0}
<!-- Badge ULTRA PREMIUM com gradiente e brilho -->
{#if count?.data && count.data > 0}
<span
class="absolute -top-1.5 -right-1.5 flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-red-500 via-error to-red-600 text-white text-xs font-black shadow-2xl ring-4 ring-white z-20"
style="animation: badge-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
class="absolute -top-1 -right-1 flex h-8 w-8 items-center justify-center rounded-full text-white text-xs font-black z-20"
style="
background: linear-gradient(135deg, #ff416c, #ff4b2b);
box-shadow:
0 8px 24px -4px rgba(255, 65, 108, 0.6),
0 4px 12px -2px rgba(255, 75, 43, 0.4),
0 0 0 3px rgba(255, 255, 255, 0.3),
0 0 0 5px rgba(255, 65, 108, 0.2);
animation: badge-bounce 2s ease-in-out infinite;
"
>
{count > 9 ? "9+" : count}
{count.data > 9 ? "9+" : count.data}
</span>
{/if}
<!-- Indicador de arrastável -->
<div class="absolute -bottom-2 left-1/2 transform -translate-x-1/2 flex gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
<div class="w-1 h-1 rounded-full bg-white"></div>
<div class="w-1 h-1 rounded-full bg-white"></div>
<div class="w-1 h-1 rounded-full bg-white"></div>
</div>
</button>
{/if}
<!-- Janela do Chat -->
<!-- Janela do Chat ULTRA MODERNA E ARRASTÁVEL -->
{#if isOpen && !isMinimized}
<div
class="fixed flex flex-col bg-base-100 rounded-2xl shadow-2xl border border-base-300 overflow-hidden
w-[400px] h-[600px] max-w-[calc(100vw-3rem)] max-h-[calc(100vh-3rem)]
md:w-[400px] md:h-[600px]
sm:w-full sm:h-full sm:bottom-0 sm:right-0 sm:rounded-none sm:max-w-full sm:max-h-full"
style="z-index: 99999 !important; animation: slideIn 0.3s ease-out; bottom: 1.5rem !important; right: 1.5rem !important; position: fixed !important;"
class="fixed flex flex-col overflow-hidden backdrop-blur-2xl"
style="
z-index: 99999 !important;
bottom: {position.y === 0 ? '1.5rem' : `${window.innerHeight - position.y - 680}px`};
right: {position.x === 0 ? '1.5rem' : `${window.innerWidth - position.x - 440}px`};
width: 440px;
height: 680px;
max-width: calc(100vw - 3rem);
max-height: calc(100vh - 3rem);
position: fixed !important;
background: linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(249,250,251,0.98) 100%);
border-radius: 24px;
box-shadow:
0 32px 64px -12px rgba(0, 0, 0, 0.15),
0 16px 32px -8px rgba(0, 0, 0, 0.1),
0 0 0 1px rgba(0, 0, 0, 0.05),
0 0 0 1px rgba(255, 255, 255, 0.5) inset;
animation: slideInScale 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
transition: {isAnimating ? 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)' : 'none'};
"
>
<!-- Header Premium -->
<!-- Header ULTRA PREMIUM com gradiente glassmorphism -->
<div
class="flex items-center justify-between px-5 py-4 bg-gradient-to-r from-primary via-primary to-primary/90 text-white border-b border-white/10 shadow-lg"
class="flex items-center justify-between px-6 py-5 text-white relative overflow-hidden"
style="
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
box-shadow: 0 8px 32px -4px rgba(102, 126, 234, 0.3);
cursor: {isDragging ? 'grabbing' : 'grab'};
"
onmousedown={handleMouseDown}
role="button"
tabindex="0"
aria-label="Arrastar janela do chat"
>
<h2 class="text-lg font-bold flex items-center gap-3">
<!-- Ícone premium do chat -->
<div class="relative">
<div class="absolute inset-0 bg-white/20 rounded-lg blur-md"></div>
<!-- Efeitos de fundo animados -->
<div class="absolute inset-0 opacity-30" style="background: radial-gradient(circle at 20% 50%, rgba(255,255,255,0.3) 0%, transparent 50%);"></div>
<div class="absolute inset-0 opacity-20" style="background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.1) 50%, transparent 70%); animation: shimmer 3s infinite;"></div>
<!-- Título com ícone moderno 3D -->
<h2 class="text-xl font-bold flex items-center gap-3 relative z-10">
<!-- Ícone de chat com efeito glassmorphism -->
<div class="relative flex items-center justify-center w-10 h-10 rounded-xl" style="background: rgba(255,255,255,0.2); backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0,0,0,0.1), 0 0 0 1px rgba(255,255,255,0.2) inset;">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
fill="none"
stroke="currentColor"
class="w-7 h-7 relative z-10"
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
style="filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
/>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<line x1="9" y1="10" x2="15" y2="10"/>
<line x1="9" y1="14" x2="13" y2="14"/>
</svg>
</div>
<span class="tracking-wide" style="text-shadow: 0 2px 4px rgba(0,0,0,0.2);">Mensagens</span>
<span class="tracking-wide font-extrabold" style="text-shadow: 0 2px 8px rgba(0,0,0,0.3); letter-spacing: 0.02em;">Mensagens</span>
</h2>
<div class="flex items-center gap-1">
<!-- Botão minimizar premium -->
<!-- Botões de controle modernos -->
<div class="flex items-center gap-2 relative z-10">
<!-- Botão minimizar MODERNO -->
<button
type="button"
class="btn btn-ghost btn-sm btn-circle hover:bg-white/20 transition-all duration-300 group"
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden"
style="background: rgba(255,255,255,0.15); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.2);"
onclick={handleMinimize}
aria-label="Minimizar"
>
<div class="absolute inset-0 bg-white/0 group-hover:bg-white/20 transition-colors duration-300"></div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
fill="none"
stroke="currentColor"
class="w-5 h-5 group-hover:scale-110 transition-transform"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 relative z-10 group-hover:scale-110 transition-transform duration-300"
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
<!-- Botão fechar premium -->
<!-- Botão fechar MODERNO -->
<button
type="button"
class="btn btn-ghost btn-sm btn-circle hover:bg-error/20 hover:text-error-content transition-all duration-300 group"
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden"
style="background: rgba(255,255,255,0.15); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.2);"
onclick={handleClose}
aria-label="Fechar"
>
<div class="absolute inset-0 bg-red-500/0 group-hover:bg-red-500/30 transition-colors duration-300"></div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
fill="none"
stroke="currentColor"
class="w-5 h-5 group-hover:scale-110 group-hover:rotate-90 transition-all duration-300"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 relative z-10 group-hover:scale-110 group-hover:rotate-90 transition-all duration-300"
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18 18 6M6 6l12 12"
/>
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
@@ -192,26 +347,68 @@
{/if}
<style>
@keyframes badge-pulse {
/* Animação do badge com bounce suave */
@keyframes badge-bounce {
0%, 100% {
transform: scale(1);
opacity: 1;
transform: scale(1) translateY(0);
}
50% {
transform: scale(1.15);
opacity: 0.9;
transform: scale(1.08) translateY(-2px);
}
}
@keyframes slideIn {
from {
/* Animação de entrada da janela com escala e bounce */
@keyframes slideInScale {
0% {
opacity: 0;
transform: translateY(20px) scale(0.95);
transform: translateY(30px) scale(0.9);
}
to {
60% {
transform: translateY(-5px) scale(1.02);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Ondas de pulso para o botão flutuante */
@keyframes pulse-ring {
0% {
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.5);
}
50% {
box-shadow: 0 0 0 15px rgba(102, 126, 234, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0);
}
}
/* Rotação para anel de brilho */
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Efeito shimmer para o header */
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
/* Suavizar transições */
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

View File

@@ -6,6 +6,7 @@
import MessageList from "./MessageList.svelte";
import MessageInput from "./MessageInput.svelte";
import UserStatusBadge from "./UserStatusBadge.svelte";
import UserAvatar from "./UserAvatar.svelte";
import ScheduleMessageModal from "./ScheduleMessageModal.svelte";
interface Props {
@@ -19,8 +20,17 @@
const conversas = useQuery(api.chat.listarConversas, {});
const conversa = $derived(() => {
if (!conversas) return null;
return conversas.find((c: any) => c._id === conversaId);
console.log("🔍 [ChatWindow] Buscando conversa ID:", conversaId);
console.log("📋 [ChatWindow] Conversas disponíveis:", conversas?.data);
if (!conversas?.data || !Array.isArray(conversas.data)) {
console.log("⚠️ [ChatWindow] conversas.data não é um array ou está vazio");
return null;
}
const encontrada = conversas.data.find((c: any) => c._id === conversaId);
console.log("✅ [ChatWindow] Conversa encontrada:", encontrada);
return encontrada;
});
function getNomeConversa(): string {
@@ -89,11 +99,20 @@
<!-- Avatar e Info -->
<div class="relative flex-shrink-0">
<div
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-xl"
>
{getAvatarConversa()}
</div>
{#if conversa() && conversa()?.tipo === "individual" && conversa()?.outroUsuario}
<UserAvatar
avatar={conversa()?.outroUsuario?.avatar}
fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl}
nome={conversa()?.outroUsuario?.nome || "Usuário"}
size="md"
/>
{:else}
<div
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-xl"
>
{getAvatarConversa()}
</div>
{/if}
{#if getStatusConversa()}
<div class="absolute bottom-0 right-0">
<UserStatusBadge status={getStatusConversa()} size="sm" />
@@ -122,27 +141,28 @@
<!-- Botões de ação -->
<div class="flex items-center gap-1">
<!-- Botão Agendar -->
<!-- Botão Agendar MODERNO -->
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
style="background: rgba(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.2);"
onclick={() => (showScheduleModal = true)}
aria-label="Agendar mensagem"
title="Agendar mensagem"
>
<div class="absolute inset-0 bg-purple-500/0 group-hover:bg-purple-500/10 transition-colors duration-300"></div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
fill="none"
stroke="currentColor"
class="w-5 h-5"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-purple-500 relative z-10 group-hover:scale-110 transition-transform"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</button>
</div>

View File

@@ -17,6 +17,24 @@
let enviando = $state(false);
let uploadingFile = $state(false);
let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null;
let showEmojiPicker = $state(false);
// Emojis mais usados
const emojis = [
"😀", "😃", "😄", "😁", "😅", "😂", "🤣", "😊", "😇", "🙂",
"🙃", "😉", "😌", "😍", "🥰", "😘", "😗", "😙", "😚", "😋",
"😛", "😝", "😜", "🤪", "🤨", "🧐", "🤓", "😎", "🥳", "😏",
"👍", "👎", "👏", "🙌", "🤝", "🙏", "💪", "✨", "🎉", "🎊",
"❤️", "💙", "💚", "💛", "🧡", "💜", "🖤", "🤍", "💯", "🔥",
];
function adicionarEmoji(emoji: string) {
mensagem += emoji;
showEmojiPicker = false;
if (textarea) {
textarea.focus();
}
}
// Auto-resize do textarea
function handleInput() {
@@ -40,19 +58,28 @@
const texto = mensagem.trim();
if (!texto || enviando) return;
console.log("📤 [MessageInput] Enviando mensagem:", {
conversaId,
conteudo: texto,
tipo: "texto",
});
try {
enviando = true;
await client.mutation(api.chat.enviarMensagem, {
const result = await client.mutation(api.chat.enviarMensagem, {
conversaId,
conteudo: texto,
tipo: "texto",
});
console.log("✅ [MessageInput] Mensagem enviada com sucesso! ID:", result);
mensagem = "";
if (textarea) {
textarea.style.height = "auto";
}
} catch (error) {
console.error("Erro ao enviar mensagem:", error);
console.error("❌ [MessageInput] Erro ao enviar mensagem:", error);
alert("Erro ao enviar mensagem");
} finally {
enviando = false;
@@ -128,8 +155,12 @@
<div class="p-4">
<div class="flex items-end gap-2">
<!-- Botão de anexar arquivo -->
<label class="btn btn-ghost btn-sm btn-circle flex-shrink-0">
<!-- Botão de anexar arquivo MODERNO -->
<label
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden cursor-pointer flex-shrink-0"
style="background: rgba(102, 126, 234, 0.1); border: 1px solid rgba(102, 126, 234, 0.2);"
title="Anexar arquivo"
>
<input
type="file"
class="hidden"
@@ -137,26 +168,76 @@
disabled={uploadingFile || enviando}
accept="*/*"
/>
<div class="absolute inset-0 bg-primary/0 group-hover:bg-primary/10 transition-colors duration-300"></div>
{#if uploadingFile}
<span class="loading loading-spinner loading-xs"></span>
<span class="loading loading-spinner loading-sm relative z-10"></span>
{:else}
<!-- Ícone de clipe moderno -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
fill="none"
stroke="currentColor"
class="w-5 h-5"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-primary relative z-10 group-hover:scale-110 transition-transform"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"
/>
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
{/if}
</label>
<!-- Botão de EMOJI MODERNO -->
<div class="relative flex-shrink-0">
<button
type="button"
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden"
style="background: rgba(255, 193, 7, 0.1); border: 1px solid rgba(255, 193, 7, 0.2);"
onclick={() => (showEmojiPicker = !showEmojiPicker)}
disabled={enviando || uploadingFile}
aria-label="Adicionar emoji"
title="Adicionar emoji"
>
<div class="absolute inset-0 bg-warning/0 group-hover:bg-warning/10 transition-colors duration-300"></div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-warning relative z-10 group-hover:scale-110 transition-transform"
>
<circle cx="12" cy="12" r="10"/>
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
<line x1="9" y1="9" x2="9.01" y2="9"/>
<line x1="15" y1="9" x2="15.01" y2="9"/>
</svg>
</button>
<!-- Picker de Emojis -->
{#if showEmojiPicker}
<div
class="absolute bottom-full left-0 mb-2 p-3 bg-base-100 rounded-xl shadow-2xl border border-base-300 z-50"
style="width: 280px; max-height: 200px; overflow-y-auto;"
>
<div class="grid grid-cols-10 gap-1">
{#each emojis as emoji}
<button
type="button"
class="text-2xl hover:scale-125 transition-transform cursor-pointer p-1 hover:bg-base-200 rounded"
onclick={() => adicionarEmoji(emoji)}
>
{emoji}
</button>
{/each}
</div>
</div>
{/if}
</div>
<!-- Textarea -->
<div class="flex-1 relative">
<textarea
@@ -171,30 +252,27 @@
></textarea>
</div>
<!-- Botão de enviar -->
<!-- Botão de enviar MODERNO -->
<button
type="button"
class="btn btn-primary btn-circle flex-shrink-0"
class="flex items-center justify-center w-12 h-12 rounded-xl transition-all duration-300 group relative overflow-hidden flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={handleEnviar}
disabled={!mensagem.trim() || enviando || uploadingFile}
aria-label="Enviar"
>
<div class="absolute inset-0 bg-white/0 group-hover:bg-white/10 transition-colors duration-300"></div>
{#if enviando}
<span class="loading loading-spinner loading-sm"></span>
<span class="loading loading-spinner loading-sm relative z-10 text-white"></span>
{:else}
<!-- Ícone de avião de papel moderno -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
fill="currentColor"
class="w-5 h-5 text-white relative z-10 group-hover:scale-110 group-hover:translate-x-1 transition-all"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"
/>
<path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.405z"/>
</svg>
{/if}
</button>
@@ -202,7 +280,7 @@
<!-- Informação sobre atalhos -->
<p class="text-xs text-base-content/50 mt-2 text-center">
Pressione Enter para enviar, Shift+Enter para quebrar linha
💡 Enter para enviar Shift+Enter para quebrar linha • 😊 Clique no emoji
</p>
</div>

View File

@@ -19,9 +19,18 @@
let messagesContainer: HTMLDivElement;
let shouldScrollToBottom = true;
// DEBUG: Log quando mensagens mudam
$effect(() => {
console.log("💬 [MessageList] Mensagens atualizadas:", {
conversaId,
count: mensagens?.data?.length || 0,
mensagens: mensagens?.data,
});
});
// Auto-scroll para a última mensagem
$effect(() => {
if (mensagens && shouldScrollToBottom && messagesContainer) {
if (mensagens?.data && shouldScrollToBottom && messagesContainer) {
tick().then(() => {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
});
@@ -30,8 +39,8 @@
// Marcar como lida quando mensagens carregam
$effect(() => {
if (mensagens && mensagens.length > 0) {
const ultimaMensagem = mensagens[mensagens.length - 1];
if (mensagens?.data && mensagens.data.length > 0) {
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
client.mutation(api.chat.marcarComoLida, {
conversaId,
mensagemId: ultimaMensagem._id as any,
@@ -98,8 +107,8 @@
bind:this={messagesContainer}
onscroll={handleScroll}
>
{#if mensagens && mensagens.length > 0}
{@const gruposPorDia = agruparMensagensPorDia(mensagens)}
{#if mensagens?.data && mensagens.data.length > 0}
{@const gruposPorDia = agruparMensagensPorDia(mensagens.data)}
{#each Object.entries(gruposPorDia) as [dia, mensagensDia]}
<!-- Separador de dia -->
<div class="flex items-center justify-center my-4">
@@ -110,7 +119,7 @@
<!-- Mensagens do dia -->
{#each mensagensDia as mensagem (mensagem._id)}
{@const isMinha = mensagem.remetente?._id === mensagens[0]?.remetente?._id}
{@const isMinha = mensagem.remetente?._id === mensagens.data[0]?.remetente?._id}
<div class={`flex mb-4 ${isMinha ? "justify-end" : "justify-start"}`}>
<div class={`max-w-[75%] ${isMinha ? "items-end" : "items-start"}`}>
<!-- Nome do remetente (apenas se não for minha) -->
@@ -203,7 +212,7 @@
{/each}
<!-- Indicador de digitação -->
{#if digitando && digitando.length > 0}
{#if digitando?.data && digitando.data.length > 0}
<div class="flex items-center gap-2 mb-4">
<div class="flex items-center gap-1">
<div class="w-2 h-2 rounded-full bg-base-content/50 animate-bounce"></div>
@@ -217,13 +226,13 @@
></div>
</div>
<p class="text-xs text-base-content/60">
{digitando.map((u: any) => u.nome).join(", ")} {digitando.length === 1
{digitando.data.map((u: any) => u.nome).join(", ")} {digitando.data.length === 1
? "está digitando"
: "estão digitando"}...
</p>
</div>
{/if}
{:else if !mensagens}
{:else if !mensagens?.data}
<!-- Loading -->
<div class="flex items-center justify-center h-full">
<span class="loading loading-spinner loading-lg"></span>

View File

@@ -8,16 +8,42 @@
// Queries e Client
const client = useConvexClient();
const notificacoes = useQuery(api.chat.obterNotificacoes, { apenasPendentes: true });
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
const notificacoesQuery = useQuery(api.chat.obterNotificacoes, { apenasPendentes: true });
const countQuery = useQuery(api.chat.contarNotificacoesNaoLidas, {});
let dropdownOpen = $state(false);
let notificacoesFerias = $state<any[]>([]);
// Helpers para obter valores das queries
const count = $derived((typeof countQuery === 'number' ? countQuery : countQuery?.data) ?? 0);
const notificacoes = $derived((Array.isArray(notificacoesQuery) ? notificacoesQuery : notificacoesQuery?.data) ?? []);
// Atualizar contador no store
$effect(() => {
if (count !== undefined) {
notificacoesCount.set(count);
const totalNotificacoes = count + (notificacoesFerias?.length || 0);
notificacoesCount.set(totalNotificacoes);
});
// Buscar notificações de férias
async function buscarNotificacoesFerias() {
try {
const usuarioStore = await import("$lib/stores/auth.svelte").then(m => m.authStore);
if (usuarioStore.usuario?._id) {
const notifsFerias = await client.query(api.ferias.obterNotificacoesNaoLidas, {
usuarioId: usuarioStore.usuario._id as any,
});
notificacoesFerias = notifsFerias || [];
}
} catch (e) {
console.error("Erro ao buscar notificações de férias:", e);
}
}
// Atualizar notificações de férias periodicamente
$effect(() => {
buscarNotificacoesFerias();
const interval = setInterval(buscarNotificacoesFerias, 30000); // A cada 30s
return () => clearInterval(interval);
});
function formatarTempo(timestamp: number): string {
@@ -33,7 +59,12 @@
async function handleMarcarTodasLidas() {
await client.mutation(api.chat.marcarTodasNotificacoesLidas, {});
// Marcar todas as notificações de férias como lidas
for (const notif of notificacoesFerias) {
await client.mutation(api.ferias.marcarComoLida, { notificacaoId: notif._id });
}
dropdownOpen = false;
await buscarNotificacoesFerias();
}
async function handleClickNotificacao(notificacaoId: string) {
@@ -41,6 +72,14 @@
dropdownOpen = false;
}
async function handleClickNotificacaoFerias(notificacaoId: string) {
await client.mutation(api.ferias.marcarComoLida, { notificacaoId: notificacaoId as any });
await buscarNotificacoesFerias();
dropdownOpen = false;
// Redirecionar para a página de férias
window.location.href = "/recursos-humanos/ferias";
}
function toggleDropdown() {
dropdownOpen = !dropdownOpen;
}
@@ -68,50 +107,80 @@
transform: scale(1.1);
}
}
@keyframes pulse-ring-subtle {
0%, 100% {
opacity: 0.1;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(1.05);
}
}
@keyframes bell-ring {
0%, 100% {
transform: rotate(0deg);
}
10%, 30% {
transform: rotate(-10deg);
}
20%, 40% {
transform: rotate(10deg);
}
50% {
transform: rotate(0deg);
}
}
</style>
<div class="dropdown dropdown-end notification-bell">
<!-- Botão de Notificação ULTRA MODERNO (igual ao perfil) -->
<button
type="button"
tabindex="0"
class="btn btn-ghost btn-circle relative hover:bg-gradient-to-br hover:from-primary/10 hover:to-primary/5 transition-all duration-500 group"
class="relative flex items-center justify-center w-14 h-14 rounded-2xl overflow-hidden group transition-all duration-300 hover:scale-105"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={toggleDropdown}
aria-label="Notificações"
>
<!-- Glow effect -->
<!-- Efeito de brilho no hover -->
<div class="absolute inset-0 bg-gradient-to-br from-white/0 to-white/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<!-- Anel de pulso sutil -->
<div class="absolute inset-0 rounded-2xl" style="animation: pulse-ring-subtle 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;"></div>
<!-- Glow effect quando tem notificações -->
{#if count && count > 0}
<div class="absolute inset-0 rounded-full bg-error/20 blur-xl animate-pulse"></div>
<div class="absolute inset-0 rounded-2xl bg-error/30 blur-lg animate-pulse"></div>
{/if}
<!-- Ícone do sino premium -->
<!-- Ícone do sino PREENCHIDO moderno -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
stroke="currentColor"
class="w-7 h-7 relative z-10 transition-all duration-500 group-hover:scale-110 group-hover:-rotate-12 {count && count > 0 ? 'text-error drop-shadow-[0_0_8px_rgba(239,68,68,0.5)]' : 'text-primary'}"
style="filter: {count && count > 0 ? 'drop-shadow(0 0 4px rgba(239,68,68,0.4))' : 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))'}"
fill="currentColor"
class="w-7 h-7 text-white relative z-10 transition-all duration-300 group-hover:scale-110"
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3)); animation: {count && count > 0 ? 'bell-ring 2s ease-in-out infinite' : 'none'};"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"
/>
<path fill-rule="evenodd" d="M5.25 9a6.75 6.75 0 0113.5 0v.75c0 2.123.8 4.057 2.118 5.52a.75.75 0 01-.297 1.206c-1.544.57-3.16.99-4.831 1.243a3.75 3.75 0 11-7.48 0 24.585 24.585 0 01-4.831-1.244.75.75 0 01-.298-1.205A8.217 8.217 0 005.25 9.75V9zm4.502 8.9a2.25 2.25 0 104.496 0 25.057 25.057 0 01-4.496 0z" clip-rule="evenodd" />
</svg>
<!-- Badge premium com gradiente -->
{#if count && count > 0}
<!-- Badge premium MODERNO com gradiente -->
{#if count + (notificacoesFerias?.length || 0) > 0}
{@const totalCount = count + (notificacoesFerias?.length || 0)}
<span
class="absolute -top-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-gradient-to-br from-red-500 via-error to-red-600 text-white text-[10px] font-black shadow-xl ring-2 ring-white z-20"
style="animation: badge-bounce 2s ease-in-out infinite;"
class="absolute -top-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full text-white text-[10px] font-black shadow-xl ring-2 ring-white z-20"
style="background: linear-gradient(135deg, #ff416c, #ff4b2b); box-shadow: 0 8px 24px -4px rgba(255, 65, 108, 0.6), 0 4px 12px -2px rgba(255, 75, 43, 0.4); animation: badge-bounce 2s ease-in-out infinite;"
>
{count > 9 ? "9+" : count}
{totalCount > 9 ? "9+" : totalCount}
</span>
{/if}
</button>
{#if dropdownOpen}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
tabindex="0"
class="dropdown-content z-50 mt-3 w-80 max-h-96 overflow-auto rounded-box bg-base-100 p-2 shadow-2xl border border-base-300"
@@ -119,7 +188,7 @@
<!-- Header -->
<div class="flex items-center justify-between px-4 py-2 border-b border-base-300">
<h3 class="text-lg font-semibold">Notificações</h3>
{#if count && count > 0}
{#if count > 0}
<button
type="button"
class="btn btn-ghost btn-xs"
@@ -132,7 +201,7 @@
<!-- Lista de notificações -->
<div class="py-2">
{#if notificacoes && notificacoes.length > 0}
{#if notificacoes.length > 0}
{#each notificacoes.slice(0, 10) as notificacao (notificacao._id)}
<button
type="button"
@@ -212,7 +281,48 @@
</div>
</button>
{/each}
{:else}
{/if}
<!-- Notificações de Férias -->
{#if notificacoesFerias.length > 0}
{#if notificacoes.length > 0}
<div class="divider my-2 text-xs">Férias</div>
{/if}
{#each notificacoesFerias.slice(0, 5) as notificacao (notificacao._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors"
onclick={() => handleClickNotificacaoFerias(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="flex-shrink-0 mt-1">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content">
{notificacao.mensagem}
</p>
<p class="text-xs text-base-content/50 mt-1">
{formatarTempo(notificacao._creationTime)}
</p>
</div>
<!-- Badge -->
<div class="flex-shrink-0">
<div class="badge badge-primary badge-xs"></div>
</div>
</div>
</button>
{/each}
{/if}
<!-- Sem notificações -->
{#if notificacoes.length === 0 && notificacoesFerias.length === 0}
<div class="px-4 py-8 text-center text-base-content/50">
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -19,6 +19,11 @@
let data = $state("");
let hora = $state("");
let loading = $state(false);
// Rastrear mudanças nas mensagens agendadas
$effect(() => {
console.log("📅 [ScheduleModal] Mensagens agendadas atualizadas:", mensagensAgendadas?.data);
});
// Definir data/hora mínima (agora)
const now = new Date();
@@ -61,7 +66,11 @@
mensagem = "";
data = "";
hora = "";
alert("Mensagem agendada com sucesso!");
// Dar tempo para o Convex processar e recarregar a lista
setTimeout(() => {
alert("Mensagem agendada com sucesso!");
}, 500);
} catch (error) {
console.error("Erro ao agendar mensagem:", error);
alert("Erro ao agendar mensagem");
@@ -90,29 +99,69 @@
}
</script>
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" onclick={onClose}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50"
onclick={onClose}
onkeydown={(e) => e.key === 'Escape' && onClose()}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bg-base-100 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col m-4"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
<h2 class="text-xl font-semibold">Agendar Mensagem</h2>
<!-- Header ULTRA MODERNO -->
<div class="flex items-center justify-between px-6 py-5 relative overflow-hidden" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);">
<!-- Efeitos de fundo -->
<div class="absolute inset-0 opacity-20" style="background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.1) 50%, transparent 70%); animation: shimmer 3s infinite;"></div>
<h2 id="modal-title" class="text-xl font-bold flex items-center gap-3 text-white relative z-10">
<!-- Ícone moderno de relógio -->
<div class="relative flex items-center justify-center w-10 h-10 rounded-xl" style="background: rgba(255,255,255,0.2); backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0,0,0,0.1);">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
>
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<span style="text-shadow: 0 2px 8px rgba(0,0,0,0.3);">Agendar Mensagem</span>
</h2>
<!-- Botão fechar moderno -->
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden z-10"
style="background: rgba(255,255,255,0.15); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.2);"
onclick={onClose}
aria-label="Fechar"
>
<div class="absolute inset-0 bg-red-500/0 group-hover:bg-red-500/30 transition-colors duration-300"></div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
fill="none"
stroke="currentColor"
class="w-5 h-5"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-white relative z-10 group-hover:scale-110 group-hover:rotate-90 transition-all duration-300"
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
@@ -125,26 +174,29 @@
<h3 class="card-title text-lg">Nova Mensagem Agendada</h3>
<div class="form-control">
<label class="label">
<label class="label" for="mensagem-input">
<span class="label-text">Mensagem</span>
</label>
<textarea
id="mensagem-input"
class="textarea textarea-bordered h-24"
placeholder="Digite a mensagem..."
bind:value={mensagem}
maxlength="500"
aria-describedby="char-count"
></textarea>
<label class="label">
<span class="label-text-alt">{mensagem.length}/500</span>
</label>
<div class="label">
<span id="char-count" class="label-text-alt">{mensagem.length}/500</span>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<label class="label" for="data-input">
<span class="label-text">Data</span>
</label>
<input
id="data-input"
type="date"
class="input input-bordered"
bind:value={data}
@@ -153,10 +205,11 @@
</div>
<div class="form-control">
<label class="label">
<label class="label" for="hora-input">
<span class="label-text">Hora</span>
</label>
<input
id="hora-input"
type="time"
class="input input-bordered"
bind:value={hora}
@@ -186,32 +239,38 @@
{/if}
<div class="card-actions justify-end">
<!-- Botão AGENDAR ultra moderno -->
<button
type="button"
class="btn btn-primary"
class="relative px-6 py-3 rounded-xl font-bold text-white overflow-hidden transition-all duration-300 group disabled:opacity-50 disabled:cursor-not-allowed"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={handleAgendar}
disabled={loading || !mensagem.trim() || !data || !hora}
>
{#if loading}
<span class="loading loading-spinner"></span>
Agendando...
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
<!-- Efeito de brilho no hover -->
<div class="absolute inset-0 bg-white/0 group-hover:bg-white/10 transition-colors duration-300"></div>
<div class="relative z-10 flex items-center gap-2">
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
<span>Agendando...</span>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
Agendar
{/if}
class="w-5 h-5 group-hover:scale-110 transition-transform"
>
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span class="group-hover:scale-105 transition-transform">Agendar</span>
{/if}
</div>
</button>
</div>
</div>
@@ -222,9 +281,9 @@
<div class="card-body">
<h3 class="card-title text-lg">Mensagens Agendadas</h3>
{#if mensagensAgendadas && mensagensAgendadas.length > 0}
{#if mensagensAgendadas?.data && mensagensAgendadas.data.length > 0}
<div class="space-y-3">
{#each mensagensAgendadas as msg (msg._id)}
{#each mensagensAgendadas.data as msg (msg._id)}
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg">
<div class="flex-shrink-0 mt-1">
<svg
@@ -252,31 +311,35 @@
</p>
</div>
<!-- Botão cancelar moderno -->
<button
type="button"
class="btn btn-ghost btn-sm btn-circle text-error"
class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2);"
onclick={() => handleCancelar(msg._id)}
aria-label="Cancelar"
>
<div class="absolute inset-0 bg-error/0 group-hover:bg-error/20 transition-colors duration-300"></div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
fill="none"
stroke="currentColor"
class="w-5 h-5"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-error relative z-10 group-hover:scale-110 transition-transform"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
</div>
{/each}
</div>
{:else if !mensagensAgendadas}
{:else if !mensagensAgendadas?.data}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
@@ -305,3 +368,14 @@
</div>
</div>
<style>
/* Efeito shimmer para o header */
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
</style>

View File

@@ -7,31 +7,57 @@
let { status = "offline", size = "md" }: Props = $props();
const sizeClasses = {
sm: "w-2 h-2",
md: "w-3 h-3",
lg: "w-4 h-4",
sm: "w-3 h-3",
md: "w-4 h-4",
lg: "w-5 h-5",
};
const statusConfig = {
online: {
color: "bg-success",
label: "Online",
borderColor: "border-success",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#10b981"/>
<path d="M9 12l2 2 4-4" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`,
label: "🟢 Online",
},
offline: {
color: "bg-base-300",
label: "Offline",
borderColor: "border-base-300",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#9ca3af"/>
<path d="M8 8l8 8M16 8l-8 8" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>`,
label: "⚫ Offline",
},
ausente: {
color: "bg-warning",
label: "Ausente",
borderColor: "border-warning",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#f59e0b"/>
<circle cx="12" cy="6" r="1.5" fill="white"/>
<path d="M12 10v4" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>`,
label: "🟡 Ausente",
},
externo: {
color: "bg-info",
label: "Externo",
borderColor: "border-info",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#3b82f6"/>
<path d="M8 12h8M12 8v8" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>`,
label: "🔵 Externo",
},
em_reuniao: {
color: "bg-error",
label: "Em Reunião",
borderColor: "border-error",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#ef4444"/>
<rect x="8" y="8" width="8" height="8" fill="white" rx="1"/>
</svg>`,
label: "🔴 Em Reunião",
},
};
@@ -39,8 +65,11 @@
</script>
<div
class={`${sizeClasses[size]} ${config.color} rounded-full`}
class={`${sizeClasses[size]} rounded-full relative flex items-center justify-center`}
style="box-shadow: 0 2px 8px rgba(0,0,0,0.15); border: 2px solid white;"
title={config.label}
aria-label={config.label}
></div>
>
{@html config.icon}
</div>

View File

@@ -0,0 +1,393 @@
<script lang="ts">
import { onMount } from "svelte";
import { Calendar } from "@fullcalendar/core";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import multiMonthPlugin from "@fullcalendar/multimonth";
import ptBrLocale from "@fullcalendar/core/locales/pt-br";
interface Props {
periodosExistentes?: Array<{ dataInicio: string; dataFim: string; dias: number }>;
onPeriodoAdicionado?: (periodo: { dataInicio: string; dataFim: string; dias: number }) => void;
onPeriodoRemovido?: (index: number) => void;
maxPeriodos?: number;
minDiasPorPeriodo?: number;
modoVisualizacao?: "month" | "multiMonth";
readonly?: boolean;
}
let {
periodosExistentes = [],
onPeriodoAdicionado,
onPeriodoRemovido,
maxPeriodos = 3,
minDiasPorPeriodo = 5,
modoVisualizacao = "month",
readonly = false,
}: Props = $props();
let calendarEl: HTMLDivElement;
let calendar: Calendar | null = null;
let selecaoInicio: Date | null = null;
let eventos: any[] = $state([]);
// Cores dos períodos
const coresPeriodos = [
{ bg: "#667eea", border: "#5568d3", text: "#ffffff" }, // Roxo
{ bg: "#f093fb", border: "#c75ce6", text: "#ffffff" }, // Rosa
{ bg: "#4facfe", border: "#00c6ff", text: "#ffffff" }, // Azul
];
// Converter períodos existentes em eventos
function atualizarEventos() {
eventos = periodosExistentes.map((periodo, index) => ({
id: `periodo-${index}`,
title: `Período ${index + 1} (${periodo.dias} dias)`,
start: periodo.dataInicio,
end: calcularDataFim(periodo.dataFim),
backgroundColor: coresPeriodos[index % coresPeriodos.length].bg,
borderColor: coresPeriodos[index % coresPeriodos.length].border,
textColor: coresPeriodos[index % coresPeriodos.length].text,
display: "block",
extendedProps: {
index,
dias: periodo.dias,
},
}));
}
// Helper: Adicionar 1 dia à data fim (FullCalendar usa exclusive end)
function calcularDataFim(dataFim: string): string {
const data = new Date(dataFim);
data.setDate(data.getDate() + 1);
return data.toISOString().split("T")[0];
}
// Helper: Calcular dias entre datas (inclusivo)
function calcularDias(inicio: Date, fim: Date): number {
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
return diffDays;
}
// Atualizar eventos quando períodos mudam
$effect(() => {
atualizarEventos();
if (calendar) {
calendar.removeAllEvents();
calendar.addEventSource(eventos);
}
});
onMount(() => {
if (!calendarEl) return;
atualizarEventos();
calendar = new Calendar(calendarEl, {
plugins: [dayGridPlugin, interactionPlugin, multiMonthPlugin],
initialView: modoVisualizacao === "multiMonth" ? "multiMonthYear" : "dayGridMonth",
locale: ptBrLocale,
headerToolbar: {
left: "prev,next today",
center: "title",
right: modoVisualizacao === "multiMonth" ? "multiMonthYear" : "dayGridMonth",
},
height: "auto",
selectable: !readonly,
selectMirror: true,
unselectAuto: false,
events: eventos,
// Estilo customizado
buttonText: {
today: "Hoje",
month: "Mês",
multiMonthYear: "Ano",
},
// Seleção de período
select: (info) => {
if (readonly) return;
const inicio = new Date(info.startStr);
const fim = new Date(info.endStr);
fim.setDate(fim.getDate() - 1); // FullCalendar usa exclusive end
const dias = calcularDias(inicio, fim);
// Validar número de períodos
if (periodosExistentes.length >= maxPeriodos) {
alert(`Máximo de ${maxPeriodos} períodos permitidos`);
calendar?.unselect();
return;
}
// Validar mínimo de dias
if (dias < minDiasPorPeriodo) {
alert(`Período deve ter no mínimo ${minDiasPorPeriodo} dias`);
calendar?.unselect();
return;
}
// Adicionar período
const novoPeriodo = {
dataInicio: info.startStr,
dataFim: fim.toISOString().split("T")[0],
dias,
};
if (onPeriodoAdicionado) {
onPeriodoAdicionado(novoPeriodo);
}
calendar?.unselect();
},
// Click em evento para remover
eventClick: (info) => {
if (readonly) return;
const index = info.event.extendedProps.index;
if (
confirm(
`Deseja remover o Período ${index + 1} (${info.event.extendedProps.dias} dias)?`
)
) {
if (onPeriodoRemovido) {
onPeriodoRemovido(index);
}
}
},
// Tooltip ao passar mouse
eventDidMount: (info) => {
info.el.title = `Click para remover\n${info.event.title}`;
info.el.style.cursor = readonly ? "default" : "pointer";
},
// Desabilitar datas passadas
selectAllow: (selectInfo) => {
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
return new Date(selectInfo.start) >= hoje;
},
// Highlight de fim de semana
dayCellClassNames: (arg) => {
if (arg.date.getDay() === 0 || arg.date.getDay() === 6) {
return ["fc-day-weekend-custom"];
}
return [];
},
});
calendar.render();
return () => {
calendar?.destroy();
};
});
</script>
<div class="calendario-ferias-wrapper">
<!-- Header com instruções -->
{#if !readonly}
<div class="alert alert-info mb-4 shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div class="text-sm">
<p class="font-bold">Como usar:</p>
<ul class="list-disc list-inside mt-1">
<li>Clique e arraste no calendário para selecionar um período de férias</li>
<li>Clique em um período colorido para removê-lo</li>
<li>
Você pode adicionar até {maxPeriodos} períodos (mínimo {minDiasPorPeriodo} dias cada)
</li>
</ul>
</div>
</div>
{/if}
<!-- Calendário -->
<div
bind:this={calendarEl}
class="calendario-ferias shadow-2xl rounded-2xl overflow-hidden border-2 border-primary/10"
></div>
<!-- Legenda de períodos -->
{#if periodosExistentes.length > 0}
<div class="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
{#each periodosExistentes as periodo, index}
<div
class="stat bg-base-100 shadow-lg rounded-xl border-2 transition-all hover:scale-105"
style="border-color: {coresPeriodos[index % coresPeriodos.length].border}"
>
<div
class="stat-figure text-white w-12 h-12 rounded-full flex items-center justify-center text-xl font-bold"
style="background: {coresPeriodos[index % coresPeriodos.length].bg}"
>
{index + 1}
</div>
<div class="stat-title">Período {index + 1}</div>
<div class="stat-value text-2xl" style="color: {coresPeriodos[index % coresPeriodos.length].bg}">
{periodo.dias} dias
</div>
<div class="stat-desc">
{new Date(periodo.dataInicio).toLocaleDateString("pt-BR")} até
{new Date(periodo.dataFim).toLocaleDateString("pt-BR")}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
/* Calendário Premium */
.calendario-ferias {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
/* Toolbar moderna */
:global(.fc .fc-toolbar) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 1rem;
border-radius: 1rem 1rem 0 0;
color: white !important;
}
:global(.fc .fc-toolbar-title) {
color: white !important;
font-weight: 700;
font-size: 1.5rem;
}
:global(.fc .fc-button) {
background: rgba(255, 255, 255, 0.2) !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
color: white !important;
font-weight: 600;
text-transform: capitalize;
transition: all 0.3s ease;
}
:global(.fc .fc-button:hover) {
background: rgba(255, 255, 255, 0.3) !important;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
:global(.fc .fc-button-active) {
background: rgba(255, 255, 255, 0.4) !important;
}
/* Cabeçalho dos dias */
:global(.fc .fc-col-header-cell) {
background: linear-gradient(180deg, #f8f9fa 0%, #e9ecef 100%);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
padding: 0.75rem 0.5rem;
color: #495057;
}
/* Células dos dias */
:global(.fc .fc-daygrid-day) {
transition: all 0.2s ease;
}
:global(.fc .fc-daygrid-day:hover) {
background: rgba(102, 126, 234, 0.05);
}
:global(.fc .fc-daygrid-day-number) {
padding: 0.5rem;
font-weight: 600;
color: #495057;
}
/* Fim de semana */
:global(.fc .fc-day-weekend-custom) {
background: rgba(255, 193, 7, 0.05);
}
/* Hoje */
:global(.fc .fc-day-today) {
background: rgba(102, 126, 234, 0.1) !important;
border: 2px solid #667eea !important;
}
/* Eventos (períodos selecionados) */
:global(.fc .fc-event) {
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
font-weight: 600;
font-size: 0.875rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
cursor: pointer;
}
:global(.fc .fc-event:hover) {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
/* Seleção (arrastar) */
:global(.fc .fc-highlight) {
background: rgba(102, 126, 234, 0.3) !important;
border: 2px dashed #667eea;
}
/* Datas desabilitadas (passado) */
:global(.fc .fc-day-past .fc-daygrid-day-number) {
opacity: 0.4;
}
/* Remover bordas padrão */
:global(.fc .fc-scrollgrid) {
border: none !important;
}
:global(.fc .fc-scrollgrid-section > td) {
border: none !important;
}
/* Grid moderno */
:global(.fc .fc-daygrid-day-frame) {
border: 1px solid #e9ecef;
min-height: 80px;
}
/* Responsivo */
@media (max-width: 768px) {
:global(.fc .fc-toolbar) {
flex-direction: column;
gap: 0.75rem;
}
:global(.fc .fc-toolbar-title) {
font-size: 1.25rem;
}
:global(.fc .fc-button) {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,394 @@
<script lang="ts">
import { useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
import { onMount } from "svelte";
interface Props {
funcionarioId: Id<"funcionarios">;
}
let { funcionarioId }: Props = $props();
// Queries
const saldosQuery = useQuery(api.saldoFerias.listarSaldos, { funcionarioId });
const solicitacoesQuery = useQuery(api.ferias.listarMinhasSolicitacoes, { funcionarioId });
const saldos = $derived(saldosQuery.data || []);
const solicitacoes = $derived(solicitacoesQuery.data || []);
// Estatísticas derivadas
const saldoAtual = $derived(saldos.find((s) => s.anoReferencia === new Date().getFullYear()));
const totalSolicitacoes = $derived(solicitacoes.length);
const aprovadas = $derived(solicitacoes.filter((s) => s.status === "aprovado" || s.status === "data_ajustada_aprovada").length);
const pendentes = $derived(solicitacoes.filter((s) => s.status === "aguardando_aprovacao").length);
const reprovadas = $derived(solicitacoes.filter((s) => s.status === "reprovado").length);
// Canvas para gráfico de pizza
let canvasSaldo = $state<HTMLCanvasElement>();
let canvasStatus = $state<HTMLCanvasElement>();
// Função para desenhar gráfico de pizza moderno
function desenharGraficoPizza(
canvas: HTMLCanvasElement,
dados: { label: string; valor: number; cor: string }[]
) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) / 2 - 20;
ctx.clearRect(0, 0, width, height);
const total = dados.reduce((acc, d) => acc + d.valor, 0);
if (total === 0) return;
let startAngle = -Math.PI / 2;
dados.forEach((item) => {
const sliceAngle = (2 * Math.PI * item.valor) / total;
// Desenhar fatia com sombra
ctx.save();
ctx.shadowColor = "rgba(0, 0, 0, 0.2)";
ctx.shadowBlur = 15;
ctx.shadowOffsetX = 5;
ctx.shadowOffsetY = 5;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fillStyle = item.cor;
ctx.fill();
ctx.restore();
// Desenhar borda branca
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 3;
ctx.stroke();
startAngle += sliceAngle;
});
// Desenhar círculo branco no centro (efeito donut)
ctx.beginPath();
ctx.arc(centerX, centerY, radius * 0.6, 0, 2 * Math.PI);
ctx.fillStyle = "#ffffff";
ctx.fill();
}
// Atualizar gráficos quando dados mudarem
$effect(() => {
if (canvasSaldo && saldoAtual) {
desenharGraficoPizza(canvasSaldo, [
{ label: "Usado", valor: saldoAtual.diasUsados, cor: "#ff6b6b" },
{ label: "Pendente", valor: saldoAtual.diasPendentes, cor: "#ffa94d" },
{ label: "Disponível", valor: saldoAtual.diasDisponiveis, cor: "#51cf66" },
]);
}
if (canvasStatus && totalSolicitacoes > 0) {
desenharGraficoPizza(canvasStatus, [
{ label: "Aprovadas", valor: aprovadas, cor: "#51cf66" },
{ label: "Pendentes", valor: pendentes, cor: "#ffa94d" },
{ label: "Reprovadas", valor: reprovadas, cor: "#ff6b6b" },
]);
}
});
</script>
<div class="dashboard-ferias">
<!-- Header -->
<div class="mb-8">
<h1 class="text-4xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
📊 Dashboard de Férias
</h1>
<p class="text-base-content/70 mt-2">Visualize seus saldos e histórico de solicitações</p>
</div>
{#if saldosQuery.isLoading || solicitacoesQuery.isLoading}
<!-- Loading Skeletons -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{#each Array(4) as _}
<div class="skeleton h-32 rounded-2xl"></div>
{/each}
</div>
{:else}
<!-- Cards de Estatísticas -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Card 1: Saldo Disponível -->
<div
class="stat bg-gradient-to-br from-success/20 to-success/5 border-2 border-success/30 shadow-2xl rounded-2xl hover:scale-105 transition-all duration-300"
>
<div class="stat-figure text-success">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block w-10 h-10 stroke-current"
>
<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"
></path>
</svg>
</div>
<div class="stat-title text-success font-semibold">Disponível</div>
<div class="stat-value text-success text-4xl">{saldoAtual?.diasDisponiveis || 0}</div>
<div class="stat-desc text-success/70">dias para usar</div>
</div>
<!-- Card 2: Dias Usados -->
<div
class="stat bg-gradient-to-br from-error/20 to-error/5 border-2 border-error/30 shadow-2xl rounded-2xl hover:scale-105 transition-all duration-300"
>
<div class="stat-figure text-error">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block w-10 h-10 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</div>
<div class="stat-title text-error font-semibold">Usado</div>
<div class="stat-value text-error text-4xl">{saldoAtual?.diasUsados || 0}</div>
<div class="stat-desc text-error/70">dias já gozados</div>
</div>
<!-- Card 3: Pendentes -->
<div
class="stat bg-gradient-to-br from-warning/20 to-warning/5 border-2 border-warning/30 shadow-2xl rounded-2xl hover:scale-105 transition-all duration-300"
>
<div class="stat-figure text-warning">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block w-10 h-10 stroke-current"
>
<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"
></path>
</svg>
</div>
<div class="stat-title text-warning font-semibold">Pendentes</div>
<div class="stat-value text-warning text-4xl">{saldoAtual?.diasPendentes || 0}</div>
<div class="stat-desc text-warning/70">aguardando aprovação</div>
</div>
<!-- Card 4: Total de Direito -->
<div
class="stat bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/30 shadow-2xl rounded-2xl hover:scale-105 transition-all duration-300"
>
<div class="stat-figure text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block w-10 h-10 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
></path>
</svg>
</div>
<div class="stat-title text-primary font-semibold">Total Direito</div>
<div class="stat-value text-primary text-4xl">{saldoAtual?.diasDireito || 0}</div>
<div class="stat-desc text-primary/70">dias no ano</div>
</div>
</div>
<!-- Gráficos -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
<!-- Gráfico 1: Distribuição de Saldo -->
<div class="card bg-base-100 shadow-2xl border-2 border-base-300">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">
🥧 Distribuição de Saldo
<div class="badge badge-primary badge-lg">
Ano {saldoAtual?.anoReferencia || new Date().getFullYear()}
</div>
</h2>
{#if saldoAtual}
<div class="flex items-center justify-center">
<canvas
bind:this={canvasSaldo}
width="300"
height="300"
class="max-w-full"
></canvas>
</div>
<!-- Legenda -->
<div class="flex justify-center gap-4 mt-4 flex-wrap">
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded-full bg-[#51cf66]"></div>
<span class="text-sm font-semibold">Disponível: {saldoAtual.diasDisponiveis} dias</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded-full bg-[#ffa94d]"></div>
<span class="text-sm font-semibold">Pendente: {saldoAtual.diasPendentes} dias</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded-full bg-[#ff6b6b]"></div>
<span class="text-sm font-semibold">Usado: {saldoAtual.diasUsados} dias</span>
</div>
</div>
{:else}
<div class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>Nenhum saldo disponível para o ano atual</span>
</div>
{/if}
</div>
</div>
<!-- Gráfico 2: Status de Solicitações -->
<div class="card bg-base-100 shadow-2xl border-2 border-base-300">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">
📋 Status de Solicitações
<div class="badge badge-secondary badge-lg">Total: {totalSolicitacoes}</div>
</h2>
{#if totalSolicitacoes > 0}
<div class="flex items-center justify-center">
<canvas
bind:this={canvasStatus}
width="300"
height="300"
class="max-w-full"
></canvas>
</div>
<!-- Legenda -->
<div class="flex justify-center gap-4 mt-4 flex-wrap">
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded-full bg-[#51cf66]"></div>
<span class="text-sm font-semibold">Aprovadas: {aprovadas}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded-full bg-[#ffa94d]"></div>
<span class="text-sm font-semibold">Pendentes: {pendentes}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded-full bg-[#ff6b6b]"></div>
<span class="text-sm font-semibold">Reprovadas: {reprovadas}</span>
</div>
</div>
{:else}
<div class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>Nenhuma solicitação de férias ainda</span>
</div>
{/if}
</div>
</div>
</div>
<!-- Histórico de Saldos -->
{#if saldos.length > 0}
<div class="card bg-base-100 shadow-2xl border-2 border-base-300">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">📅 Histórico de Saldos</h2>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Ano</th>
<th>Direito</th>
<th>Usado</th>
<th>Pendente</th>
<th>Disponível</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each saldos as saldo}
<tr>
<td class="font-bold">{saldo.anoReferencia}</td>
<td>{saldo.diasDireito} dias</td>
<td><span class="badge badge-error">{saldo.diasUsados}</span></td>
<td><span class="badge badge-warning">{saldo.diasPendentes}</span></td>
<td><span class="badge badge-success">{saldo.diasDisponiveis}</span></td>
<td>
{#if saldo.status === "ativo"}
<span class="badge badge-success">Ativo</span>
{:else if saldo.status === "vencido"}
<span class="badge badge-error">Vencido</span>
{:else}
<span class="badge badge-neutral">Concluído</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
{/if}
{/if}
</div>
<style>
.bg-clip-text {
-webkit-background-clip: text;
background-clip: text;
}
canvas {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
</style>

View File

@@ -0,0 +1,688 @@
<script lang="ts">
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import CalendarioFerias from "./CalendarioFerias.svelte";
import { toast } from "svelte-sonner";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
interface Props {
funcionarioId: Id<"funcionarios">;
onSucesso?: () => void;
onCancelar?: () => void;
}
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
// Cliente Convex
const client = useConvexClient();
// Estado do wizard
let passoAtual = $state(1);
const totalPassos = 3;
// Dados da solicitação
let anoSelecionado = $state(new Date().getFullYear());
let periodosFerias: Array<{ dataInicio: string; dataFim: string; dias: number }> = $state([]);
let observacao = $state("");
let processando = $state(false);
// Queries
const saldoQuery = $derived(
useQuery(api.saldoFerias.obterSaldo, {
funcionarioId,
anoReferencia: anoSelecionado,
})
);
const validacaoQuery = $derived(
periodosFerias.length > 0
? useQuery(api.saldoFerias.validarSolicitacao, {
funcionarioId,
anoReferencia: anoSelecionado,
periodos: periodosFerias.map((p) => ({
dataInicio: p.dataInicio,
dataFim: p.dataFim,
})),
})
: { data: null }
);
// Derivados
const saldo = $derived(saldoQuery.data);
const validacao = $derived(validacaoQuery.data);
const totalDiasSelecionados = $derived(
periodosFerias.reduce((acc, p) => acc + p.dias, 0)
);
// Anos disponíveis (últimos 3 anos + próximo ano)
const anosDisponiveis = $derived.by(() => {
const anoAtual = new Date().getFullYear();
return [anoAtual - 1, anoAtual, anoAtual + 1];
});
// Configurações do calendário (baseado no saldo/regime)
const maxPeriodos = $derived(saldo?.regimeTrabalho?.includes("Servidor") ? 2 : 3);
const minDiasPorPeriodo = $derived(
saldo?.regimeTrabalho?.includes("Servidor") ? 10 : 5
);
// Funções
function proximoPasso() {
if (passoAtual === 1 && !saldo) {
toast.error("Selecione um ano com saldo disponível");
return;
}
if (passoAtual === 2 && periodosFerias.length === 0) {
toast.error("Selecione pelo menos 1 período de férias");
return;
}
if (passoAtual === 2 && validacao && !validacao.valido) {
toast.error("Corrija os erros antes de continuar");
return;
}
if (passoAtual < totalPassos) {
passoAtual++;
}
}
function passoAnterior() {
if (passoAtual > 1) {
passoAtual--;
}
}
async function enviarSolicitacao() {
if (!validacao || !validacao.valido) {
toast.error("Valide os períodos antes de enviar");
return;
}
processando = true;
try {
await client.mutation(api.ferias.criarSolicitacao, {
funcionarioId,
anoReferencia: anoSelecionado,
periodos: periodosFerias.map((p) => ({
dataInicio: p.dataInicio,
dataFim: p.dataFim,
diasCorridos: p.dias,
})),
observacao: observacao || undefined,
});
toast.success("Solicitação de férias enviada com sucesso! 🎉");
if (onSucesso) onSucesso();
} catch (error: any) {
toast.error(error.message || "Erro ao enviar solicitação");
} finally {
processando = false;
}
}
function handlePeriodoAdicionado(periodo: {
dataInicio: string;
dataFim: string;
dias: number;
}) {
periodosFerias = [...periodosFerias, periodo];
toast.success(`Período de ${periodo.dias} dias adicionado! ✅`);
}
function handlePeriodoRemovido(index: number) {
const removido = periodosFerias[index];
periodosFerias = periodosFerias.filter((_, i) => i !== index);
toast.info(`Período de ${removido.dias} dias removido`);
}
</script>
<div class="wizard-ferias-container">
<!-- Progress Bar -->
<div class="mb-8">
<div class="flex justify-between items-center">
{#each Array(totalPassos) as _, i}
<div class="flex items-center flex-1">
<!-- Círculo do passo -->
<div
class="relative flex items-center justify-center w-12 h-12 rounded-full font-bold transition-all duration-300"
class:bg-primary={passoAtual > i + 1}
class:text-white={passoAtual > i + 1}
class:border-4={passoAtual === i + 1}
class:border-primary={passoAtual === i + 1}
class:bg-base-200={passoAtual < i + 1}
class:text-base-content={passoAtual < i + 1}
style:box-shadow={passoAtual === i + 1 ? "0 0 20px rgba(102, 126, 234, 0.5)" : "none"}
>
{#if passoAtual > i + 1}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
{:else}
{i + 1}
{/if}
</div>
<!-- Linha conectora -->
{#if i < totalPassos - 1}
<div
class="flex-1 h-1 mx-2 transition-all duration-300"
class:bg-primary={passoAtual > i + 1}
class:bg-base-300={passoAtual <= i + 1}
></div>
{/if}
</div>
{/each}
</div>
<!-- Labels dos passos -->
<div class="flex justify-between mt-4 px-1">
<div class="text-center flex-1">
<p class="text-sm font-semibold" class:text-primary={passoAtual === 1}>Ano & Saldo</p>
</div>
<div class="text-center flex-1">
<p class="text-sm font-semibold" class:text-primary={passoAtual === 2}>Períodos</p>
</div>
<div class="text-center flex-1">
<p class="text-sm font-semibold" class:text-primary={passoAtual === 3}>Confirmação</p>
</div>
</div>
</div>
<!-- Conteúdo dos Passos -->
<div class="wizard-content">
<!-- PASSO 1: Ano & Saldo -->
{#if passoAtual === 1}
<div class="passo-content animate-fadeIn">
<h2 class="text-3xl font-bold mb-6 text-center bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
Escolha o Ano de Referência
</h2>
<!-- Seletor de Ano -->
<div class="grid grid-cols-3 gap-4 mb-8">
{#each anosDisponiveis as ano}
<button
type="button"
class="btn btn-lg transition-all duration-300 hover:scale-105"
class:btn-primary={anoSelecionado === ano}
class:btn-outline={anoSelecionado !== ano}
onclick={() => (anoSelecionado = ano)}
>
{ano}
</button>
{/each}
</div>
<!-- Card de Saldo -->
{#if saldoQuery.isLoading}
<div class="skeleton h-64 w-full rounded-2xl"></div>
{:else if saldo}
<div
class="card bg-gradient-to-br from-primary/10 to-secondary/10 shadow-2xl border-2 border-primary/20"
>
<div class="card-body">
<h3 class="card-title text-2xl mb-4">
📊 Saldo de Férias {anoSelecionado}
</h3>
<div class="stats stats-vertical lg:stats-horizontal shadow-lg w-full">
<div class="stat">
<div class="stat-figure text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block w-8 h-8 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
></path>
</svg>
</div>
<div class="stat-title">Total Direito</div>
<div class="stat-value text-primary">{saldo.diasDireito}</div>
<div class="stat-desc">dias no ano</div>
</div>
<div class="stat">
<div class="stat-figure text-success">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block w-8 h-8 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
<div class="stat-title">Disponível</div>
<div class="stat-value text-success">{saldo.diasDisponiveis}</div>
<div class="stat-desc">para usar</div>
</div>
<div class="stat">
<div class="stat-figure text-warning">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block w-8 h-8 stroke-current"
>
<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"
></path>
</svg>
</div>
<div class="stat-title">Usado</div>
<div class="stat-value text-warning">{saldo.diasUsados}</div>
<div class="stat-desc">até agora</div>
</div>
</div>
<!-- Informações do Regime -->
<div class="alert alert-info mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div>
<h4 class="font-bold">{saldo.regimeTrabalho}</h4>
<p class="text-sm">
Período aquisitivo: {new Date(saldo.dataInicio).toLocaleDateString("pt-BR")}
a {new Date(saldo.dataFim).toLocaleDateString("pt-BR")}
</p>
</div>
</div>
{#if saldo.diasDisponiveis === 0}
<div class="alert alert-warning mt-4">
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>Você não tem saldo disponível para este ano.</span>
</div>
{/if}
</div>
</div>
{:else}
<div class="alert alert-warning">
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>Nenhum saldo encontrado para este ano.</span>
</div>
{/if}
</div>
{/if}
<!-- PASSO 2: Seleção de Períodos -->
{#if passoAtual === 2}
<div class="passo-content animate-fadeIn">
<h2 class="text-3xl font-bold mb-6 text-center bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
Selecione os Períodos de Férias
</h2>
<!-- Resumo rápido -->
<div class="alert bg-base-200 mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info shrink-0 w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div>
<p>
<strong>Saldo disponível:</strong>
{saldo?.diasDisponiveis || 0} dias | <strong>Selecionados:</strong>
{totalDiasSelecionados} dias | <strong>Restante:</strong>
{(saldo?.diasDisponiveis || 0) - totalDiasSelecionados} dias
</p>
</div>
</div>
<!-- Calendário -->
<CalendarioFerias
periodosExistentes={periodosFerias}
onPeriodoAdicionado={handlePeriodoAdicionado}
onPeriodoRemovido={handlePeriodoRemovido}
maxPeriodos={maxPeriodos}
minDiasPorPeriodo={minDiasPorPeriodo}
modoVisualizacao="month">
</CalendarioFerias>
<!-- Validações -->
{#if validacao && periodosFerias.length > 0}
<div class="mt-6">
{#if validacao.valido}
<div class="alert alert-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>
<span>✅ Períodos válidos! Total: {validacao.totalDias} dias</span>
</div>
{:else}
<div class="alert alert-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>
<div>
<p class="font-bold">Erros encontrados:</p>
<ul class="list-disc list-inside">
{#each validacao.erros as erro}
<li>{erro}</li>
{/each}
</ul>
</div>
</div>
{/if}
{#if validacao.avisos.length > 0}
<div class="alert alert-warning mt-4">
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div>
<p class="font-bold">Avisos:</p>
<ul class="list-disc list-inside">
{#each validacao.avisos as aviso}
<li>{aviso}</li>
{/each}
</ul>
</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- PASSO 3: Confirmação -->
{#if passoAtual === 3}
<div class="passo-content animate-fadeIn">
<h2 class="text-3xl font-bold mb-6 text-center bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
Confirme sua Solicitação
</h2>
<!-- Resumo Final -->
<div class="card bg-base-100 shadow-2xl">
<div class="card-body">
<h3 class="card-title text-xl mb-4">📝 Resumo da Solicitação</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title">Ano de Referência</div>
<div class="stat-value text-primary">{anoSelecionado}</div>
</div>
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title">Total de Dias</div>
<div class="stat-value text-success">{totalDiasSelecionados}</div>
</div>
</div>
<h4 class="font-bold text-lg mb-2">Períodos Selecionados:</h4>
<div class="space-y-3">
{#each periodosFerias as periodo, index}
<div class="flex items-center gap-4 p-4 bg-base-200 rounded-lg">
<div
class="badge badge-lg badge-primary font-bold text-white w-12 h-12 flex items-center justify-center"
>
{index + 1}
</div>
<div class="flex-1">
<p class="font-semibold">
{new Date(periodo.dataInicio).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "long",
year: "numeric",
})}
até
{new Date(periodo.dataFim).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "long",
year: "numeric",
})}
</p>
<p class="text-sm text-base-content/70">{periodo.dias} dias corridos</p>
</div>
</div>
{/each}
</div>
<!-- Campo de Observação -->
<div class="form-control mt-6">
<label for="observacao" class="label">
<span class="label-text font-semibold">Observações (opcional)</span>
</label>
<textarea
id="observacao"
class="textarea textarea-bordered h-24"
placeholder="Adicione alguma observação ou justificativa..."
bind:value={observacao}
></textarea>
</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Botões de Navegação -->
<div class="flex justify-between mt-8">
<div>
{#if passoAtual > 1}
<button type="button" class="btn btn-outline btn-lg gap-2" onclick={passoAnterior}>
<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="M15 19l-7-7 7-7"
/>
</svg>
Voltar
</button>
{:else if onCancelar}
<button type="button" class="btn btn-ghost btn-lg" onclick={onCancelar}>
Cancelar
</button>
{/if}
</div>
<div>
{#if passoAtual < totalPassos}
<button
type="button"
class="btn btn-primary btn-lg gap-2"
onclick={proximoPasso}
disabled={passoAtual === 1 && (!saldo || saldo.diasDisponiveis === 0)}
>
Próximo
<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 5l7 7-7 7"
/>
</svg>
</button>
{:else}
<button
type="button"
class="btn btn-success btn-lg gap-2"
onclick={enviarSolicitacao}
disabled={processando}
>
{#if processando}
<span class="loading loading-spinner"></span>
Enviando...
{: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>
Enviar Solicitação
{/if}
</button>
{/if}
</div>
</div>
</div>
<style>
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.5s ease-out;
}
.wizard-ferias-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.passo-content {
min-height: 500px;
}
/* Gradiente no texto */
.bg-clip-text {
-webkit-background-clip: text;
background-clip: text;
}
/* Responsive */
@media (max-width: 768px) {
.wizard-ferias-container {
padding: 1rem;
}
.passo-content {
min-height: 400px;
}
}
</style>

View File

@@ -0,0 +1,377 @@
<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";
let { onClose }: { onClose: () => void } = $props();
const client = useConvexClient();
const alertas = useQuery(api.monitoramento.listarAlertas, {});
// Estado para novo alerta
let editingAlertId = $state<Id<"alertConfigurations"> | null>(null);
let metricName = $state("cpuUsage");
let threshold = $state(80);
let operator = $state<">" | "<" | ">=" | "<=" | "==">(">");
let enabled = $state(true);
let notifyByEmail = $state(false);
let notifyByChat = $state(true);
let saving = $state(false);
let showForm = $state(false);
const metricOptions = [
{ value: "cpuUsage", label: "Uso de CPU (%)" },
{ value: "memoryUsage", label: "Uso de Memória (%)" },
{ value: "networkLatency", label: "Latência de Rede (ms)" },
{ value: "storageUsed", label: "Armazenamento Usado (%)" },
{ value: "usuariosOnline", label: "Usuários Online" },
{ value: "mensagensPorMinuto", label: "Mensagens por Minuto" },
{ value: "tempoRespostaMedio", label: "Tempo de Resposta (ms)" },
{ value: "errosCount", label: "Contagem de Erros" },
];
const operatorOptions = [
{ value: ">", label: "Maior que (>)" },
{ value: ">=", label: "Maior ou igual (≥)" },
{ value: "<", label: "Menor que (<)" },
{ value: "<=", label: "Menor ou igual (≤)" },
{ value: "==", label: "Igual a (=)" },
];
function resetForm() {
editingAlertId = null;
metricName = "cpuUsage";
threshold = 80;
operator = ">";
enabled = true;
notifyByEmail = false;
notifyByChat = true;
showForm = false;
}
function editAlert(alert: any) {
editingAlertId = alert._id;
metricName = alert.metricName;
threshold = alert.threshold;
operator = alert.operator;
enabled = alert.enabled;
notifyByEmail = alert.notifyByEmail;
notifyByChat = alert.notifyByChat;
showForm = true;
}
async function saveAlert() {
saving = true;
try {
await client.mutation(api.monitoramento.configurarAlerta, {
alertId: editingAlertId || undefined,
metricName,
threshold,
operator,
enabled,
notifyByEmail,
notifyByChat,
});
resetForm();
} catch (error) {
console.error("Erro ao salvar alerta:", error);
alert("Erro ao salvar alerta. Tente novamente.");
} finally {
saving = false;
}
}
async function deleteAlert(alertId: Id<"alertConfigurations">) {
if (!confirm("Tem certeza que deseja deletar este alerta?")) return;
try {
await client.mutation(api.monitoramento.deletarAlerta, { alertId });
} catch (error) {
console.error("Erro ao deletar alerta:", error);
alert("Erro ao deletar alerta. Tente novamente.");
}
}
function getMetricLabel(metricName: string): string {
return metricOptions.find(m => m.value === metricName)?.label || metricName;
}
function getOperatorLabel(op: string): string {
return operatorOptions.find(o => o.value === op)?.label || op;
}
</script>
<dialog class="modal modal-open">
<div class="modal-box max-w-4xl bg-gradient-to-br from-base-100 to-base-200">
<button
type="button"
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onclick={onClose}
>
</button>
<h3 class="font-bold text-3xl text-primary mb-2">⚙️ Configuração de Alertas</h3>
<p class="text-base-content/60 mb-6">Configure alertas personalizados para monitoramento do sistema</p>
<!-- Botão Novo Alerta -->
{#if !showForm}
<button
type="button"
class="btn btn-primary mb-6"
onclick={() => showForm = true}
>
<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 Alerta
</button>
{/if}
<!-- Formulário de Alerta -->
{#if showForm}
<div class="card bg-base-100 shadow-xl mb-6 border-2 border-primary/20">
<div class="card-body">
<h4 class="card-title text-xl">
{editingAlertId ? "Editar Alerta" : "Novo Alerta"}
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<!-- Métrica -->
<div class="form-control">
<label class="label" for="metric">
<span class="label-text font-semibold">Métrica</span>
</label>
<select
id="metric"
class="select select-bordered select-primary"
bind:value={metricName}
>
{#each metricOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<!-- Operador -->
<div class="form-control">
<label class="label" for="operator">
<span class="label-text font-semibold">Condição</span>
</label>
<select
id="operator"
class="select select-bordered select-primary"
bind:value={operator}
>
{#each operatorOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<!-- Threshold -->
<div class="form-control">
<label class="label" for="threshold">
<span class="label-text font-semibold">Valor Limite</span>
</label>
<input
id="threshold"
type="number"
class="input input-bordered input-primary"
bind:value={threshold}
min="0"
step="1"
/>
</div>
<!-- Ativo -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<span class="label-text font-semibold">Alerta Ativo</span>
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={enabled}
/>
</label>
</div>
</div>
<!-- Notificações -->
<div class="divider">Método de Notificação</div>
<div class="flex gap-6">
<label class="label cursor-pointer gap-3">
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={notifyByChat}
/>
<span class="label-text">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
Notificar por Chat
</span>
</label>
<label class="label cursor-pointer gap-3">
<input
type="checkbox"
class="checkbox checkbox-secondary"
bind:checked={notifyByEmail}
/>
<span class="label-text">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2" 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>
Notificar por E-mail
</span>
</label>
</div>
<!-- Preview -->
<div class="alert alert-info mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h4 class="font-bold">Preview do Alerta:</h4>
<p class="text-sm">
Alertar quando <strong>{getMetricLabel(metricName)}</strong> for
<strong>{getOperatorLabel(operator)}</strong> a <strong>{threshold}</strong>
</p>
</div>
</div>
<!-- Botões -->
<div class="card-actions justify-end mt-4">
<button
type="button"
class="btn btn-ghost"
onclick={resetForm}
disabled={saving}
>
Cancelar
</button>
<button
type="button"
class="btn btn-primary"
onclick={saveAlert}
disabled={saving || (!notifyByChat && !notifyByEmail)}
>
{#if saving}
<span class="loading loading-spinner"></span>
Salvando...
{: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>
Salvar Alerta
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Lista de Alertas -->
<div class="divider">Alertas Configurados</div>
{#if alertas && alertas.length > 0}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Métrica</th>
<th>Condição</th>
<th>Status</th>
<th>Notificações</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each alertas as alerta}
<tr class={!alerta.enabled ? "opacity-50" : ""}>
<td>
<div class="font-semibold">{getMetricLabel(alerta.metricName)}</div>
</td>
<td>
<div class="badge badge-outline">
{getOperatorLabel(alerta.operator)} {alerta.threshold}
</div>
</td>
<td>
{#if alerta.enabled}
<div class="badge badge-success gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" 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>
Ativo
</div>
{:else}
<div class="badge badge-ghost gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
Inativo
</div>
{/if}
</td>
<td>
<div class="flex gap-1">
{#if alerta.notifyByChat}
<div class="badge badge-primary badge-sm">Chat</div>
{/if}
{#if alerta.notifyByEmail}
<div class="badge badge-secondary badge-sm">Email</div>
{/if}
</div>
</td>
<td>
<div class="flex gap-2">
<button
type="button"
class="btn btn-ghost btn-xs"
onclick={() => editAlert(alerta)}
>
<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>
</button>
<button
type="button"
class="btn btn-ghost btn-xs text-error"
onclick={() => deleteAlert(alerta._id)}
>
<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>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="alert">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>Nenhum alerta configurado. Clique em "Novo Alerta" para criar um.</span>
</div>
{/if}
<div class="modal-action">
<button type="button" class="btn btn-lg" onclick={onClose}>Fechar</button>
</div>
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<form method="dialog" class="modal-backdrop" onclick={onClose}>
<button type="button">close</button>
</form>
</dialog>

View File

@@ -0,0 +1,445 @@
<script lang="ts">
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { format, subDays, startOfDay, endOfDay } from "date-fns";
import { ptBR } from "date-fns/locale";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import Papa from "papaparse";
let { onClose }: { onClose: () => void } = $props();
const client = useConvexClient();
// Estados
let periodType = $state("custom");
let dataInicio = $state(format(subDays(new Date(), 7), "yyyy-MM-dd"));
let dataFim = $state(format(new Date(), "yyyy-MM-dd"));
let horaInicio = $state("00:00");
let horaFim = $state("23:59");
let generating = $state(false);
// Métricas selecionadas
let selectedMetrics = $state({
cpuUsage: true,
memoryUsage: true,
networkLatency: true,
storageUsed: true,
usuariosOnline: true,
mensagensPorMinuto: true,
tempoRespostaMedio: true,
errosCount: true,
});
const metricLabels: Record<string, string> = {
cpuUsage: "Uso de CPU (%)",
memoryUsage: "Uso de Memória (%)",
networkLatency: "Latência de Rede (ms)",
storageUsed: "Armazenamento (%)",
usuariosOnline: "Usuários Online",
mensagensPorMinuto: "Mensagens/min",
tempoRespostaMedio: "Tempo Resposta (ms)",
errosCount: "Erros",
};
function setPeriod(type: string) {
periodType = type;
const now = new Date();
switch (type) {
case "today":
dataInicio = format(now, "yyyy-MM-dd");
dataFim = format(now, "yyyy-MM-dd");
break;
case "week":
dataInicio = format(subDays(now, 7), "yyyy-MM-dd");
dataFim = format(now, "yyyy-MM-dd");
break;
case "month":
dataInicio = format(subDays(now, 30), "yyyy-MM-dd");
dataFim = format(now, "yyyy-MM-dd");
break;
}
}
function getDateRange(): { inicio: number; fim: number } {
const inicio = startOfDay(new Date(`${dataInicio}T${horaInicio}`)).getTime();
const fim = endOfDay(new Date(`${dataFim}T${horaFim}`)).getTime();
return { inicio, fim };
}
async function generatePDF() {
generating = true;
try {
const { inicio, fim } = getDateRange();
const relatorio = await client.query(api.monitoramento.gerarRelatorio, {
dataInicio: inicio,
dataFim: fim,
});
const doc = new jsPDF();
// Título
doc.setFontSize(20);
doc.setTextColor(102, 126, 234); // Primary color
doc.text("Relatório de Monitoramento do Sistema", 14, 20);
// Subtítulo com período
doc.setFontSize(12);
doc.setTextColor(0, 0, 0);
doc.text(
`Período: ${format(inicio, "dd/MM/yyyy HH:mm", { locale: ptBR })} até ${format(fim, "dd/MM/yyyy HH:mm", { locale: ptBR })}`,
14,
30
);
// Informações gerais
doc.setFontSize(10);
doc.text(`Gerado em: ${format(new Date(), "dd/MM/yyyy HH:mm", { locale: ptBR })}`, 14, 38);
doc.text(`Total de registros: ${relatorio.metricas.length}`, 14, 44);
// Estatísticas
let yPos = 55;
doc.setFontSize(14);
doc.setTextColor(102, 126, 234);
doc.text("Estatísticas do Período", 14, yPos);
yPos += 10;
const statsData: any[] = [];
Object.entries(selectedMetrics).forEach(([metric, selected]) => {
if (selected && relatorio.estatisticas[metric]) {
const stats = relatorio.estatisticas[metric];
if (stats) {
statsData.push([
metricLabels[metric],
stats.min.toFixed(2),
stats.max.toFixed(2),
stats.avg.toFixed(2),
]);
}
}
});
autoTable(doc, {
startY: yPos,
head: [["Métrica", "Mínimo", "Máximo", "Média"]],
body: statsData,
theme: "striped",
headStyles: { fillColor: [102, 126, 234] },
});
// Dados detalhados (últimos 50 registros)
const finalY = (doc as any).lastAutoTable.finalY || yPos + 10;
yPos = finalY + 15;
doc.setFontSize(14);
doc.setTextColor(102, 126, 234);
doc.text("Registros Detalhados (Últimos 50)", 14, yPos);
yPos += 10;
const detailsData = relatorio.metricas.slice(0, 50).map((m) => {
const row = [format(m.timestamp, "dd/MM HH:mm", { locale: ptBR })];
Object.entries(selectedMetrics).forEach(([metric, selected]) => {
if (selected) {
row.push((m[metric] || 0).toFixed(1));
}
});
return row;
});
const headers = ["Data/Hora"];
Object.entries(selectedMetrics).forEach(([metric, selected]) => {
if (selected) {
headers.push(metricLabels[metric]);
}
});
autoTable(doc, {
startY: yPos,
head: [headers],
body: detailsData,
theme: "grid",
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 8 },
});
// 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 Gestão da Secretaria de Esportes | Página ${i} de ${pageCount}`,
doc.internal.pageSize.getWidth() / 2,
doc.internal.pageSize.getHeight() - 10,
{ align: "center" }
);
}
// Salvar
doc.save(`relatorio-monitoramento-${format(new Date(), "yyyy-MM-dd-HHmm")}.pdf`);
} catch (error) {
console.error("Erro ao gerar PDF:", error);
alert("Erro ao gerar relatório PDF. Tente novamente.");
} finally {
generating = false;
}
}
async function generateCSV() {
generating = true;
try {
const { inicio, fim } = getDateRange();
const relatorio = await client.query(api.monitoramento.gerarRelatorio, {
dataInicio: inicio,
dataFim: fim,
});
// Preparar dados para CSV
const csvData = relatorio.metricas.map((m) => {
const row: any = {
"Data/Hora": format(m.timestamp, "dd/MM/yyyy HH:mm:ss", { locale: ptBR }),
};
Object.entries(selectedMetrics).forEach(([metric, selected]) => {
if (selected) {
row[metricLabels[metric]] = m[metric] || 0;
}
});
return row;
});
// Gerar CSV
const csv = Papa.unparse(csvData);
// Download
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", `relatorio-monitoramento-${format(new Date(), "yyyy-MM-dd-HHmm")}.csv`);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error("Erro ao gerar CSV:", error);
alert("Erro ao gerar relatório CSV. Tente novamente.");
} finally {
generating = false;
}
}
function toggleAllMetrics(value: boolean) {
Object.keys(selectedMetrics).forEach((key) => {
selectedMetrics[key] = value;
});
}
</script>
<dialog class="modal modal-open">
<div class="modal-box max-w-3xl bg-gradient-to-br from-base-100 to-base-200">
<button
type="button"
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onclick={onClose}
>
</button>
<h3 class="font-bold text-3xl text-primary mb-2">📊 Gerador de Relatórios</h3>
<p class="text-base-content/60 mb-6">Exporte dados de monitoramento em PDF ou CSV</p>
<!-- Seleção de Período -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h4 class="card-title text-xl">Período</h4>
<!-- Botões de Período Rápido -->
<div class="flex gap-2 mb-4">
<button
type="button"
class="btn btn-sm {periodType === 'today' ? 'btn-primary' : 'btn-outline'}"
onclick={() => setPeriod('today')}
>
Hoje
</button>
<button
type="button"
class="btn btn-sm {periodType === 'week' ? 'btn-primary' : 'btn-outline'}"
onclick={() => setPeriod('week')}
>
Última Semana
</button>
<button
type="button"
class="btn btn-sm {periodType === 'month' ? 'btn-primary' : 'btn-outline'}"
onclick={() => setPeriod('month')}
>
Último Mês
</button>
<button
type="button"
class="btn btn-sm {periodType === 'custom' ? 'btn-primary' : 'btn-outline'}"
onclick={() => periodType = 'custom'}
>
Personalizado
</button>
</div>
{#if periodType === 'custom'}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="dataInicio">
<span class="label-text font-semibold">Data Início</span>
</label>
<input
id="dataInicio"
type="date"
class="input input-bordered input-primary"
bind:value={dataInicio}
/>
</div>
<div class="form-control">
<label class="label" for="horaInicio">
<span class="label-text font-semibold">Hora Início</span>
</label>
<input
id="horaInicio"
type="time"
class="input input-bordered input-primary"
bind:value={horaInicio}
/>
</div>
<div class="form-control">
<label class="label" for="dataFim">
<span class="label-text font-semibold">Data Fim</span>
</label>
<input
id="dataFim"
type="date"
class="input input-bordered input-primary"
bind:value={dataFim}
/>
</div>
<div class="form-control">
<label class="label" for="horaFim">
<span class="label-text font-semibold">Hora Fim</span>
</label>
<input
id="horaFim"
type="time"
class="input input-bordered input-primary"
bind:value={horaFim}
/>
</div>
</div>
{/if}
</div>
</div>
<!-- Seleção de Métricas -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h4 class="card-title text-xl">Métricas a Incluir</h4>
<div class="flex gap-2">
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={() => toggleAllMetrics(true)}
>
Selecionar Todas
</button>
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={() => toggleAllMetrics(false)}
>
Limpar
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each Object.entries(metricLabels) as [metric, label]}
<label class="label cursor-pointer justify-start gap-3 hover:bg-base-200 rounded-lg p-2">
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={selectedMetrics[metric]}
/>
<span class="label-text">{label}</span>
</label>
{/each}
</div>
</div>
</div>
<!-- Botões de Exportação -->
<div class="flex gap-3 justify-end">
<button
type="button"
class="btn btn-outline"
onclick={onClose}
disabled={generating}
>
Cancelar
</button>
<button
type="button"
class="btn btn-secondary"
onclick={generateCSV}
disabled={generating || !Object.values(selectedMetrics).some(v => v)}
>
{#if generating}
<span class="loading loading-spinner"></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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
{/if}
Exportar CSV
</button>
<button
type="button"
class="btn btn-primary"
onclick={generatePDF}
disabled={generating || !Object.values(selectedMetrics).some(v => v)}
>
{#if generating}
<span class="loading loading-spinner"></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="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>
</div>
{#if !Object.values(selectedMetrics).some(v => v)}
<div class="alert alert-warning mt-4">
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Selecione pelo menos uma métrica para gerar o relatório.</span>
</div>
{/if}
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<form method="dialog" class="modal-backdrop" onclick={onClose}>
<button type="button">close</button>
</form>
</dialog>

View File

@@ -0,0 +1,258 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { startMetricsCollection } from "$lib/utils/metricsCollector";
import AlertConfigModal from "./AlertConfigModal.svelte";
import ReportGeneratorModal from "./ReportGeneratorModal.svelte";
const client = useConvexClient();
const ultimaMetrica = useQuery(api.monitoramento.obterUltimaMetrica, {});
let showAlertModal = $state(false);
let showReportModal = $state(false);
let stopCollection: (() => void) | null = null;
// Métricas derivadas
const metrics = $derived(ultimaMetrica || null);
// Função para obter cor baseada no valor
function getStatusColor(value: number | undefined, type: "normal" | "inverted" = "normal"): string {
if (value === undefined) return "badge-ghost";
if (type === "normal") {
// Para CPU, RAM, Storage: maior é pior
if (value < 60) return "badge-success";
if (value < 80) return "badge-warning";
return "badge-error";
} else {
// Para métricas onde menor é melhor (latência, erros)
if (value < 100) return "badge-success";
if (value < 500) return "badge-warning";
return "badge-error";
}
}
function getProgressColor(value: number | undefined): string {
if (value === undefined) return "progress-ghost";
if (value < 60) return "progress-success";
if (value < 80) return "progress-warning";
return "progress-error";
}
// Iniciar coleta de métricas ao montar
onMount(() => {
stopCollection = startMetricsCollection(client, 2000); // Atualização a cada 2 segundos
});
// Parar coleta ao desmontar
onDestroy(() => {
if (stopCollection) {
stopCollection();
}
});
function formatValue(value: number | undefined, suffix: string = "%"): string {
if (value === undefined) return "N/A";
return `${value.toFixed(1)}${suffix}`;
}
</script>
<div class="card bg-gradient-to-br from-base-100 to-base-200 shadow-2xl border-2 border-primary/20">
<div class="card-body">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
<div class="flex items-center gap-2">
<div class="badge badge-success badge-lg gap-2 animate-pulse">
<div class="w-2 h-2 bg-white rounded-full"></div>
Tempo Real - Atualização a cada 2s
</div>
</div>
<div class="flex gap-2">
<button
type="button"
class="btn btn-primary btn-sm"
onclick={() => showAlertModal = true}
>
<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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
Configurar Alertas
</button>
<button
type="button"
class="btn btn-secondary btn-sm"
onclick={() => showReportModal = true}
>
<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 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>
Gerar Relatório
</button>
</div>
</div>
<!-- Métricas Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- CPU Usage -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-primary/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
</div>
<div class="stat-title font-semibold">CPU</div>
<div class="stat-value text-primary text-3xl">{formatValue(metrics?.cpuUsage)}</div>
<div class="stat-desc mt-2">
<div class="badge {getStatusColor(metrics?.cpuUsage)} badge-sm">
{metrics?.cpuUsage !== undefined && metrics.cpuUsage < 60 ? "Normal" :
metrics?.cpuUsage !== undefined && metrics.cpuUsage < 80 ? "Atenção" : "Crítico"}
</div>
</div>
<progress class="progress {getProgressColor(metrics?.cpuUsage)} w-full mt-2" value={metrics?.cpuUsage || 0} max="100"></progress>
</div>
<!-- Memory Usage -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-success/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-success">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
</div>
<div class="stat-title font-semibold">Memória RAM</div>
<div class="stat-value text-success text-3xl">{formatValue(metrics?.memoryUsage)}</div>
<div class="stat-desc mt-2">
<div class="badge {getStatusColor(metrics?.memoryUsage)} badge-sm">
{metrics?.memoryUsage !== undefined && metrics.memoryUsage < 60 ? "Normal" :
metrics?.memoryUsage !== undefined && metrics.memoryUsage < 80 ? "Atenção" : "Crítico"}
</div>
</div>
<progress class="progress {getProgressColor(metrics?.memoryUsage)} w-full mt-2" value={metrics?.memoryUsage || 0} max="100"></progress>
</div>
<!-- Network Latency -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-warning/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
</div>
<div class="stat-title font-semibold">Latência de Rede</div>
<div class="stat-value text-warning text-3xl">{formatValue(metrics?.networkLatency, "ms")}</div>
<div class="stat-desc mt-2">
<div class="badge {getStatusColor(metrics?.networkLatency, 'inverted')} badge-sm">
{metrics?.networkLatency !== undefined && metrics.networkLatency < 100 ? "Excelente" :
metrics?.networkLatency !== undefined && metrics.networkLatency < 500 ? "Boa" : "Lenta"}
</div>
</div>
<progress class="progress progress-warning w-full mt-2" value={Math.min((metrics?.networkLatency || 0) / 10, 100)} max="100"></progress>
</div>
<!-- Storage Usage -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-info/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-info">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
</div>
<div class="stat-title font-semibold">Armazenamento</div>
<div class="stat-value text-info text-3xl">{formatValue(metrics?.storageUsed)}</div>
<div class="stat-desc mt-2">
<div class="badge {getStatusColor(metrics?.storageUsed)} badge-sm">
{metrics?.storageUsed !== undefined && metrics.storageUsed < 60 ? "Normal" :
metrics?.storageUsed !== undefined && metrics.storageUsed < 80 ? "Atenção" : "Crítico"}
</div>
</div>
<progress class="progress progress-info w-full mt-2" value={metrics?.storageUsed || 0} max="100"></progress>
</div>
<!-- Usuários Online -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-accent/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-accent">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div class="stat-title font-semibold">Usuários Online</div>
<div class="stat-value text-accent text-3xl">{metrics?.usuariosOnline || 0}</div>
<div class="stat-desc mt-2">
<div class="badge badge-accent badge-sm">Tempo Real</div>
</div>
</div>
<!-- Mensagens por Minuto -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-secondary/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</div>
<div class="stat-title font-semibold">Mensagens/min</div>
<div class="stat-value text-secondary text-3xl">{metrics?.mensagensPorMinuto || 0}</div>
<div class="stat-desc mt-2">
<div class="badge badge-secondary badge-sm">Atividade</div>
</div>
</div>
<!-- Tempo de Resposta -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-primary/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" 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>
<div class="stat-title font-semibold">Tempo Resposta</div>
<div class="stat-value text-primary text-3xl">{formatValue(metrics?.tempoRespostaMedio, "ms")}</div>
<div class="stat-desc mt-2">
<div class="badge {getStatusColor(metrics?.tempoRespostaMedio, 'inverted')} badge-sm">
{metrics?.tempoRespostaMedio !== undefined && metrics.tempoRespostaMedio < 100 ? "Rápido" :
metrics?.tempoRespostaMedio !== undefined && metrics.tempoRespostaMedio < 500 ? "Normal" : "Lento"}
</div>
</div>
</div>
<!-- Erros -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-error/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-error">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="stat-title font-semibold">Erros (30s)</div>
<div class="stat-value text-error text-3xl">{metrics?.errosCount || 0}</div>
<div class="stat-desc mt-2">
<div class="badge {(metrics?.errosCount || 0) === 0 ? 'badge-success' : 'badge-error'} badge-sm">
{(metrics?.errosCount || 0) === 0 ? "Sem erros" : "Verificar logs"}
</div>
</div>
</div>
</div>
<!-- Info Footer -->
<div class="alert alert-info mt-6 shadow-lg">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="font-bold">Monitoramento Ativo</h3>
<div class="text-xs">
Métricas coletadas automaticamente a cada 2 segundos.
{#if metrics?.timestamp}
Última atualização: {new Date(metrics.timestamp).toLocaleString('pt-BR')}
{/if}
</div>
</div>
</div>
</div>
</div>
<!-- Modals -->
{#if showAlertModal}
<AlertConfigModal onClose={() => showAlertModal = false} />
{/if}
{#if showReportModal}
<ReportGeneratorModal onClose={() => showReportModal = false} />
{/if}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
data: any;
title?: string;
height?: number;
};
let { data, title = '', height = 300 }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
chart = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#a6adbb',
font: {
size: 12,
family: "'Inter', sans-serif",
},
usePointStyle: true,
padding: 15,
}
},
title: {
display: !!title,
text: title,
color: '#e5e7eb',
font: {
size: 16,
weight: 'bold',
family: "'Inter', sans-serif",
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#570df8',
borderWidth: 1,
padding: 12,
}
},
scales: {
x: {
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
},
y: {
beginAtZero: true,
stacked: true,
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
}
},
elements: {
line: {
tension: 0.4,
fill: true
}
},
animation: {
duration: 750,
easing: 'easeInOutQuart'
}
}
});
}
}
});
$effect(() => {
if (chart && data) {
chart.data = data;
chart.update('none');
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div style="height: {height}px;">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
data: any;
title?: string;
height?: number;
horizontal?: boolean;
};
let { data, title = '', height = 300, horizontal = false }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
chart = new Chart(ctx, {
type: horizontal ? 'bar' : 'bar',
data: data,
options: {
indexAxis: horizontal ? 'y' : 'x',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#a6adbb',
font: {
size: 12,
family: "'Inter', sans-serif",
},
usePointStyle: true,
padding: 15,
}
},
title: {
display: !!title,
text: title,
color: '#e5e7eb',
font: {
size: 16,
weight: 'bold',
family: "'Inter', sans-serif",
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#570df8',
borderWidth: 1,
padding: 12,
}
},
scales: {
x: {
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
}
},
animation: {
duration: 750,
easing: 'easeInOutQuart'
}
}
});
}
}
});
$effect(() => {
if (chart && data) {
chart.data = data;
chart.update('none');
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div style="height: {height}px;">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
data: any;
title?: string;
height?: number;
};
let { data, title = '', height = 300 }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
chart = new Chart(ctx, {
type: 'doughnut',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
color: '#a6adbb',
font: {
size: 12,
family: "'Inter', sans-serif",
},
usePointStyle: true,
padding: 15,
generateLabels: (chart) => {
const datasets = chart.data.datasets;
return chart.data.labels!.map((label, i) => ({
text: `${label}: ${datasets[0].data[i]}${typeof datasets[0].data[i] === 'number' ? '%' : ''}`,
fillStyle: datasets[0].backgroundColor![i] as string,
hidden: false,
index: i
}));
}
}
},
title: {
display: !!title,
text: title,
color: '#e5e7eb',
font: {
size: 16,
weight: 'bold',
family: "'Inter', sans-serif",
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#570df8',
borderWidth: 1,
padding: 12,
callbacks: {
label: function(context: any) {
return `${context.label}: ${context.parsed}%`;
}
}
}
},
animation: {
duration: 1000,
easing: 'easeInOutQuart'
}
}
});
}
}
});
$effect(() => {
if (chart && data) {
chart.data = data;
chart.update('none');
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div style="height: {height}px;" class="flex items-center justify-center">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,129 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
data: any;
title?: string;
height?: number;
};
let { data, title = '', height = 300 }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
chart = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#a6adbb',
font: {
size: 12,
family: "'Inter', sans-serif",
},
usePointStyle: true,
padding: 15,
}
},
title: {
display: !!title,
text: title,
color: '#e5e7eb',
font: {
size: 16,
weight: 'bold',
family: "'Inter', sans-serif",
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#570df8',
borderWidth: 1,
padding: 12,
displayColors: true,
callbacks: {
label: function(context: any) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
label += context.parsed.y.toFixed(2);
return label;
}
}
}
},
scales: {
x: {
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
}
},
animation: {
duration: 750,
easing: 'easeInOutQuart'
}
}
});
}
}
});
// Atualizar gráfico quando os dados mudarem
$effect(() => {
if (chart && data) {
chart.data = data;
chart.update('none'); // Update sem animação para performance
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div style="height: {height}px;">
<canvas bind:this={canvas}></canvas>
</div>