feat: implement vacation management system with request approval, notification handling, and employee training tracking; enhance UI components for improved user experience

This commit is contained in:
2025-10-29 22:05:29 -03:00
parent f219340cd8
commit 16bcd2ac25
21 changed files with 3910 additions and 617 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

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

@@ -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;
}
@@ -101,12 +140,13 @@
</svg>
<!-- Badge premium com gradiente -->
{#if count && count > 0}
{#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;"
>
{count > 9 ? "9+" : count}
{totalCount > 9 ? "9+" : totalCount}
</span>
{/if}
</button>
@@ -119,7 +159,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 +172,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 +252,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

@@ -6,6 +6,7 @@ interface Usuario {
matricula: string;
nome: string;
email: string;
funcionarioId?: string;
role: {
_id: string;
nome: string;

View File

@@ -1,524 +1,447 @@
<script lang="ts">
import { useQuery, useConvexClient } from "convex-svelte";
import { useConvexClient, useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { requestNotificationPermission } from "$lib/utils/notifications";
import { getAvatarUrl as generateAvatarUrl } from "$lib/utils/avatarGenerator";
import { authStore } from "$lib/stores/auth.svelte";
import SolicitarFerias from "$lib/components/SolicitarFerias.svelte";
import AprovarFerias from "$lib/components/AprovarFerias.svelte";
const client = useConvexClient();
const perfil = useQuery(api.usuarios.obterPerfil, {});
// Estados
let nome = $state("");
let email = $state("");
let matricula = $state("");
let avatarSelecionado = $state("");
let statusMensagemInput = $state("");
let statusPresencaSelect = $state("online");
let notificacoesAtivadas = $state(true);
let somNotificacao = $state(true);
let abaAtiva = $state<"meu-perfil" | "minhas-ferias" | "aprovar-ferias">("meu-perfil");
let mostrarFormSolicitar = $state(false);
let solicitacaoSelecionada = $state<any>(null);
let uploadingFoto = $state(false);
let salvando = $state(false);
let mensagemSucesso = $state("");
// Queries
const funcionarioQuery = $derived(
authStore.usuario?.funcionarioId
? useQuery(api.funcionarios.getById, { id: authStore.usuario.funcionarioId as any })
: { data: null }
);
// Sincronizar com perfil
$effect(() => {
if (perfil) {
nome = perfil.nome || "";
email = perfil.email || "";
matricula = perfil.matricula || "";
avatarSelecionado = perfil.avatar || "";
statusMensagemInput = perfil.statusMensagem || "";
statusPresencaSelect = perfil.statusPresenca || "online";
notificacoesAtivadas = perfil.notificacoesAtivadas ?? true;
somNotificacao = perfil.somNotificacao ?? true;
}
});
const minhasSolicitacoesQuery = $derived(
funcionarioQuery.data
? useQuery(api.ferias.listarMinhasSolicitacoes, { funcionarioId: funcionarioQuery.data._id })
: { data: [] }
);
// Lista de avatares profissionais usando DiceBear - TODOS FELIZES E SORRIDENTES
const avatares = [
// Avatares masculinos (16)
{ id: "avatar-m-1", seed: "John-Happy", label: "Homem 1" },
{ id: "avatar-m-2", seed: "Peter-Smile", label: "Homem 2" },
{ id: "avatar-m-3", seed: "Michael-Joy", label: "Homem 3" },
{ id: "avatar-m-4", seed: "David-Glad", label: "Homem 4" },
{ id: "avatar-m-5", seed: "James-Cheerful", label: "Homem 5" },
{ id: "avatar-m-6", seed: "Robert-Bright", label: "Homem 6" },
{ id: "avatar-m-7", seed: "William-Joyful", label: "Homem 7" },
{ id: "avatar-m-8", seed: "Joseph-Merry", label: "Homem 8" },
{ id: "avatar-m-9", seed: "Thomas-Happy", label: "Homem 9" },
{ id: "avatar-m-10", seed: "Charles-Smile", label: "Homem 10" },
{ id: "avatar-m-11", seed: "Daniel-Joy", label: "Homem 11" },
{ id: "avatar-m-12", seed: "Matthew-Glad", label: "Homem 12" },
{ id: "avatar-m-13", seed: "Anthony-Cheerful", label: "Homem 13" },
{ id: "avatar-m-14", seed: "Mark-Bright", label: "Homem 14" },
{ id: "avatar-m-15", seed: "Donald-Joyful", label: "Homem 15" },
{ id: "avatar-m-16", seed: "Steven-Merry", label: "Homem 16" },
const solicitacoesSubordinadosQuery = $derived(
authStore.usuario?._id
? useQuery(api.ferias.listarSolicitacoesSubordinados, { gestorId: authStore.usuario._id as any })
: { data: [] }
);
// Avatares femininos (16)
{ id: "avatar-f-1", seed: "Maria-Happy", label: "Mulher 1" },
{ id: "avatar-f-2", seed: "Ana-Smile", label: "Mulher 2" },
{ id: "avatar-f-3", seed: "Patricia-Joy", label: "Mulher 3" },
{ id: "avatar-f-4", seed: "Jennifer-Glad", label: "Mulher 4" },
{ id: "avatar-f-5", seed: "Linda-Cheerful", label: "Mulher 5" },
{ id: "avatar-f-6", seed: "Barbara-Bright", label: "Mulher 6" },
{ id: "avatar-f-7", seed: "Elizabeth-Joyful", label: "Mulher 7" },
{ id: "avatar-f-8", seed: "Jessica-Merry", label: "Mulher 8" },
{ id: "avatar-f-9", seed: "Sarah-Happy", label: "Mulher 9" },
{ id: "avatar-f-10", seed: "Karen-Smile", label: "Mulher 10" },
{ id: "avatar-f-11", seed: "Nancy-Joy", label: "Mulher 11" },
{ id: "avatar-f-12", seed: "Betty-Glad", label: "Mulher 12" },
{ id: "avatar-f-13", seed: "Helen-Cheerful", label: "Mulher 13" },
{ id: "avatar-f-14", seed: "Sandra-Bright", label: "Mulher 14" },
{ id: "avatar-f-15", seed: "Ashley-Joyful", label: "Mulher 15" },
{ id: "avatar-f-16", seed: "Kimberly-Merry", label: "Mulher 16" },
];
const meuTimeQuery = $derived(
funcionarioQuery.data
? useQuery(api.times.obterTimeFuncionario, { funcionarioId: funcionarioQuery.data._id })
: { data: null }
);
function getAvatarUrl(avatarId: string): string {
// Usar gerador local ao invés da API externa
return generateAvatarUrl(avatarId);
const meusTimesGestorQuery = $derived(
authStore.usuario?._id
? useQuery(api.times.listarPorGestor, { gestorId: authStore.usuario._id as any })
: { data: [] }
);
const funcionario = $derived(funcionarioQuery.data);
const minhasSolicitacoes = $derived(minhasSolicitacoesQuery?.data || []);
const solicitacoesSubordinados = $derived(solicitacoesSubordinadosQuery?.data || []);
const meuTime = $derived(meuTimeQuery?.data);
const meusTimesGestor = $derived(meusTimesGestorQuery?.data || []);
// Verificar se é gestor
const ehGestor = $derived((meusTimesGestor || []).length > 0);
async function recarregar() {
mostrarFormSolicitar = false;
solicitacaoSelecionada = null;
}
async function handleUploadFoto(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Validar tipo
if (!file.type.startsWith("image/")) {
alert("Por favor, selecione uma imagem");
return;
}
// Validar tamanho (max 2MB)
if (file.size > 2 * 1024 * 1024) {
alert("A imagem deve ter no máximo 2MB");
return;
}
try {
uploadingFoto = true;
// 1. Obter upload URL
const uploadUrl = await client.mutation(api.usuarios.uploadFotoPerfil, {});
// 2. Upload da foto
const result = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
if (!result.ok) {
throw new Error("Falha no upload");
}
const { storageId } = await result.json();
// 3. Atualizar perfil
await client.mutation(api.usuarios.atualizarPerfil, {
fotoPerfil: storageId,
avatar: "", // Limpar avatar quando usa foto
});
mensagemSucesso = "Foto de perfil atualizada com sucesso!";
setTimeout(() => (mensagemSucesso = ""), 3000);
} catch (error) {
console.error("Erro ao fazer upload:", error);
alert("Erro ao fazer upload da foto");
} finally {
uploadingFoto = false;
input.value = "";
}
async function selecionarSolicitacao(solicitacaoId: string) {
const detalhes = await client.query(api.ferias.obterDetalhes, {
solicitacaoId: solicitacaoId as any,
});
solicitacaoSelecionada = detalhes;
}
async function handleSelecionarAvatar(avatarId: string) {
try {
avatarSelecionado = avatarId;
await client.mutation(api.usuarios.atualizarPerfil, {
avatar: avatarId,
fotoPerfil: undefined, // Limpar foto quando usa avatar
});
mensagemSucesso = "Avatar atualizado com sucesso!";
setTimeout(() => (mensagemSucesso = ""), 3000);
} catch (error) {
console.error("Erro ao atualizar avatar:", error);
alert("Erro ao atualizar avatar");
}
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";
}
async function handleSalvarConfiguracoes() {
try {
salvando = true;
// Validar statusMensagem
if (statusMensagemInput.length > 100) {
alert("A mensagem de status deve ter no máximo 100 caracteres");
return;
}
await client.mutation(api.usuarios.atualizarPerfil, {
statusMensagem: statusMensagemInput.trim() || undefined,
statusPresenca: statusPresencaSelect as any,
notificacoesAtivadas,
somNotificacao,
});
mensagemSucesso = "Configurações salvas com sucesso!";
setTimeout(() => (mensagemSucesso = ""), 3000);
} catch (error) {
console.error("Erro ao salvar configurações:", error);
alert("Erro ao salvar configurações");
} finally {
salvando = false;
}
}
async function handleSolicitarNotificacoes() {
const permission = await requestNotificationPermission();
if (permission === "granted") {
await client.mutation(api.usuarios.atualizarPerfil, { notificacoesAtivadas: true });
notificacoesAtivadas = true;
} else if (permission === "denied") {
alert(
"Você negou as notificações. Para ativá-las, permita notificações nas configurações do navegador."
);
}
function getStatusTexto(status: string) {
const textos: Record<string, string> = {
aguardando_aprovacao: "Aguardando",
aprovado: "Aprovado",
reprovado: "Reprovado",
data_ajustada_aprovada: "Ajustado",
};
return textos[status] || status;
}
</script>
<div class="max-w-5xl mx-auto">
<main class="container mx-auto px-4 py-6 max-w-7xl">
<!-- Header -->
<div class="mb-6">
<h1 class="text-3xl font-bold text-base-content">Meu Perfil</h1>
<p class="text-base-content/70">Gerencie suas informações e preferências</p>
<div class="flex items-center gap-4">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-16">
<span class="text-2xl">{authStore.usuario?.nome.substring(0, 2).toUpperCase()}</span>
</div>
</div>
<div>
<h1 class="text-3xl font-bold text-primary">{authStore.usuario?.nome}</h1>
<p class="text-base-content/70">{authStore.usuario?.email}</p>
{#if meuTime}
<div class="badge badge-outline mt-1" style="border-color: {meuTime.cor}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" 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>
{meuTime.nome}
</div>
{/if}
</div>
</div>
</div>
{#if mensagemSucesso}
<div class="alert alert-success mb-6">
<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"
/>
<!-- Tabs -->
<div role="tablist" class="tabs tabs-boxed mb-6">
<button
role="tab"
class={`tab ${abaAtiva === "meu-perfil" ? "tab-active" : ""}`}
onclick={() => abaAtiva = "meu-perfil"}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>{mensagemSucesso}</span>
</div>
{/if}
Meu Perfil
</button>
{#if perfil}
<div class="grid gap-6">
<!-- Card 1: Foto de Perfil -->
<button
role="tab"
class={`tab ${abaAtiva === "minhas-ferias" ? "tab-active" : ""}`}
onclick={() => abaAtiva = "minhas-ferias"}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" 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>
Minhas Férias
</button>
{#if ehGestor}
<button
role="tab"
class={`tab ${abaAtiva === "aprovar-ferias" ? "tab-active" : ""}`}
onclick={() => abaAtiva = "aprovar-ferias"}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" 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>
Aprovar Férias
{#if (solicitacoesSubordinados || []).filter((s: any) => s.status === "aguardando_aprovacao").length > 0}
<span class="badge badge-warning badge-sm ml-2">
{(solicitacoesSubordinados || []).filter((s: any) => s.status === "aguardando_aprovacao").length}
</span>
{/if}
</button>
{/if}
</div>
<!-- Conteúdo das Abas -->
{#if abaAtiva === "meu-perfil"}
<!-- Meu Perfil -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Informações Pessoais -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Foto de Perfil</h2>
<div class="flex flex-col md:flex-row items-center gap-6">
<!-- Preview -->
<div class="flex-shrink-0">
{#if perfil.fotoPerfilUrl}
<div class="avatar">
<div class="w-40 h-40 rounded-lg">
<img src={perfil.fotoPerfilUrl} alt="Foto de perfil" class="object-cover" />
</div>
</div>
{:else if perfil.avatar || avatarSelecionado}
<div class="avatar">
<div class="w-40 h-40 rounded-lg bg-base-200 overflow-hidden">
<img
src={getAvatarUrl(perfil.avatar || avatarSelecionado)}
alt="Avatar"
class="w-full h-full object-cover"
/>
</div>
</div>
{:else}
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-lg w-40 h-40">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-20 h-20"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
/>
</svg>
</div>
</div>
{/if}
</div>
<!-- Upload -->
<div class="flex-1">
<label class="btn btn-primary btn-block gap-2">
<input
type="file"
class="hidden"
accept="image/*"
onchange={handleUploadFoto}
disabled={uploadingFoto}
/>
{#if uploadingFoto}
<span class="loading loading-spinner"></span>
Fazendo upload...
{: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
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"
/>
</svg>
Carregar Foto
{/if}
</label>
<p class="text-xs text-base-content/60 mt-2">
Máximo 2MB. Formatos: JPG, PNG, GIF, WEBP
</p>
</div>
</div>
<!-- Grid de Avatares -->
<div class="divider">OU escolha um avatar profissional</div>
<div class="alert alert-info mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.182 15.182a4.5 4.5 0 0 1-6.364 0M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z"
/>
</svg>
<h2 class="card-title mb-4">Informações Pessoais</h2>
<div class="space-y-3">
<div>
<p class="font-semibold">32 avatares disponíveis - Todos felizes e sorridentes! 😊</p>
</div>
</div>
<div class="grid grid-cols-4 md:grid-cols-8 lg:grid-cols-8 gap-3 max-h-96 overflow-y-auto p-2">
{#each avatares as avatar}
<button
type="button"
class={`relative w-full aspect-[3/4] rounded-lg overflow-hidden border-4 transition-all hover:scale-105 ${
avatarSelecionado === avatar.id
? "border-primary shadow-lg"
: "border-base-300 hover:border-primary/50"
}`}
onclick={() => handleSelecionarAvatar(avatar.id)}
title={avatar.label}
>
<img
src={getAvatarUrl(avatar.id)}
alt={avatar.label}
class="w-full h-full object-cover"
/>
{#if avatarSelecionado === avatar.id}
<div class="absolute inset-0 bg-primary/20 flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-10 h-10 text-primary"
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z"
clip-rule="evenodd"
/>
</svg>
</div>
{/if}
</button>
{/each}
</div>
</div>
</div>
<!-- Card 2: Informações Básicas -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Informações Básicas</h2>
<p class="text-sm text-base-content/70 mb-4">
Informações do seu cadastro (somente leitura)
</p>
<div class="grid md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label">
<span class="label">
<span class="label-text font-semibold">Nome</span>
</label>
<input
type="text"
class="input input-bordered bg-base-200"
value={nome}
readonly
/>
</span>
<p class="text-base-content/90">{authStore.usuario?.nome}</p>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">E-mail</span>
</label>
<input
type="email"
class="input input-bordered bg-base-200"
value={email}
readonly
/>
<div>
<span class="label">
<span class="label-text font-semibold">Email</span>
</span>
<p class="text-base-content/90">{authStore.usuario?.email}</p>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Matrícula</span>
</label>
<input
type="text"
class="input input-bordered bg-base-200"
value={matricula}
readonly
/>
<div>
<span class="label">
<span class="label-text font-semibold">Perfil</span>
</span>
<div class="badge badge-primary">{authStore.usuario?.role?.nome || "Usuário"}</div>
</div>
</div>
<div class="divider"></div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Mensagem de Status do Chat</span>
<span class="label-text-alt">{statusMensagemInput.length}/100</span>
</label>
<textarea
class="textarea textarea-bordered h-20"
placeholder="Ex: Disponível para reuniões | Em atendimento | Ausente temporariamente"
bind:value={statusMensagemInput}
maxlength="100"
></textarea>
<label class="label">
<span class="label-text-alt">Este texto aparecerá abaixo do seu nome no chat</span>
</label>
</div>
</div>
</div>
<!-- Card 3: Preferências de Chat -->
<!-- Informações de Funcionário -->
{#if funcionario}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Dados Funcionais</h2>
<div class="space-y-3">
<div>
<span class="label">
<span class="label-text font-semibold">Matrícula</span>
</span>
<p class="text-base-content/90">{funcionario.matricula || "Não informada"}</p>
</div>
<div>
<span class="label">
<span class="label-text font-semibold">CPF</span>
</span>
<p class="text-base-content/90">{funcionario.cpf}</p>
</div>
<div>
<span class="label">
<span class="label-text font-semibold">Time</span>
</span>
{#if meuTime}
<div class="flex items-center gap-2">
<div class="badge badge-lg badge-outline" style="border-color: {meuTime.cor}">
{meuTime.nome}
</div>
<span class="text-xs text-base-content/50">Gestor: {meuTime.gestor?.nome}</span>
</div>
{:else}
<p class="text-base-content/50">Não atribuído a um time</p>
{/if}
</div>
<div>
<span class="label">
<span class="label-text font-semibold">Status</span>
</span>
{#if funcionario.statusFerias === "em_ferias"}
<div class="badge badge-warning badge-lg">🏖️ Em Férias</div>
{:else}
<div class="badge badge-success badge-lg">✅ Ativo</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
<!-- Times Gerenciados (se for gestor) -->
{#if ehGestor}
<div class="card bg-gradient-to-br from-primary/10 to-primary/5 shadow-xl md:col-span-2">
<div class="card-body">
<h2 class="card-title mb-4">
<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="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>
Times que Você Gerencia
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{#each meusTimesGestor as time}
<div class="card bg-base-100 shadow border-l-4" style="border-color: {time.cor}">
<div class="card-body p-4">
<h3 class="font-bold text-lg">{time.nome}</h3>
<p class="text-sm text-base-content/70">{time.descricao || "Sem descrição"}</p>
<div class="flex items-center gap-2 mt-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span class="text-sm font-semibold">{time.membros?.length || 0} membros</span>
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
{:else if abaAtiva === "minhas-ferias"}
<!-- Minhas Férias -->
<div class="space-y-6">
<!-- Botão Nova Solicitação -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Preferências de Chat</h2>
<div class="form-control">
<label class="label">
<span class="label-text">Status de Presença</span>
</label>
<select class="select select-bordered" bind:value={statusPresencaSelect}>
<option value="online">🟢 Online</option>
<option value="ausente">🟡 Ausente</option>
<option value="externo">🔵 Externo</option>
<option value="em_reuniao">🔴 Em Reunião</option>
<option value="offline">⚫ Offline</option>
</select>
</div>
<div class="divider"></div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={notificacoesAtivadas}
/>
<div>
<span class="label-text font-medium">Notificações Ativadas</span>
<p class="text-xs text-base-content/60">
Receber notificações de novas mensagens
</p>
</div>
</label>
</div>
{#if notificacoesAtivadas && typeof Notification !== "undefined" && Notification.permission !== "granted"}
<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>Você precisa permitir notificações no navegador</span>
<button type="button" class="btn btn-sm" onclick={handleSolicitarNotificacoes}>
Permitir
</button>
<div class="flex items-center justify-between">
<div>
<h2 class="card-title text-lg">Minhas Solicitações de Férias</h2>
<p class="text-sm text-base-content/70">Solicite e acompanhe suas férias</p>
</div>
{/if}
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={somNotificacao}
/>
<div>
<span class="label-text font-medium">Som de Notificação</span>
<p class="text-xs text-base-content/60">
Tocar um som ao receber mensagens
</p>
</div>
</label>
</div>
<div class="card-actions justify-end mt-4">
<button
type="button"
class="btn btn-primary"
onclick={handleSalvarConfiguracoes}
disabled={salvando}
class="btn btn-primary gap-2"
onclick={() => mostrarFormSolicitar = !mostrarFormSolicitar}
>
{#if salvando}
<span class="loading loading-spinner"></span>
Salvando...
{:else}
Salvar Configurações
{/if}
<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>
{mostrarFormSolicitar ? "Cancelar" : "Nova Solicitação"}
</button>
</div>
{#if mostrarFormSolicitar}
<div class="divider"></div>
{#if funcionario}
<SolicitarFerias funcionarioId={funcionario._id} onSucesso={recarregar} />
{: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>
<div>
<h3 class="font-bold">Perfil de funcionário não encontrado</h3>
<div class="text-xs">Seu usuário ainda não está associado a um cadastro de funcionário. Entre em contato com o RH.</div>
</div>
</div>
{/if}
{/if}
</div>
</div>
<!-- Lista de Solicitações -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="font-bold text-lg mb-4">Histórico ({minhasSolicitacoes.length})</h3>
{#if minhasSolicitacoes.length === 0}
<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>Você ainda não tem solicitações de férias.</span>
</div>
{:else}
<div class="space-y-3">
{#each minhasSolicitacoes as solicitacao}
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h4 class="font-bold">Férias {solicitacao.anoReferencia}</h4>
<div class={`badge ${getStatusBadge(solicitacao.status)}`}>
{getStatusTexto(solicitacao.status)}
</div>
</div>
<div class="text-sm space-y-1">
<p><strong>Períodos:</strong> {solicitacao.periodos.length}</p>
<p><strong>Total:</strong> {solicitacao.periodos.reduce((acc: number, p: any) => acc + p.diasCorridos, 0)} dias</p>
{#if solicitacao.motivoReprovacao}
<p class="text-error"><strong>Motivo:</strong> {solicitacao.motivoReprovacao}</p>
{/if}
</div>
</div>
<div class="text-right text-xs text-base-content/50">
Solicitado em<br>
{new Date(solicitacao._creationTime).toLocaleDateString("pt-BR")}
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{:else}
<!-- Loading -->
<div class="flex items-center justify-center h-96">
<span class="loading loading-spinner loading-lg"></span>
{:else if abaAtiva === "aprovar-ferias"}
<!-- Aprovar Férias (Gestores) -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">
Solicitações da Equipe ({solicitacoesSubordinados.length})
</h2>
{#if solicitacoesSubordinados.length === 0}
<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>Nenhuma solicitação pendente no momento.</span>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Funcionário</th>
<th>Time</th>
<th>Ano</th>
<th>Períodos</th>
<th>Dias</th>
<th>Status</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each solicitacoesSubordinados as solicitacao}
<tr>
<td>
<div class="font-bold">{solicitacao.funcionario?.nome}</div>
</td>
<td>
{#if solicitacao.time}
<div class="badge badge-sm" style="border-color: {solicitacao.time.cor}">
{solicitacao.time.nome}
</div>
{/if}
</td>
<td>{solicitacao.anoReferencia}</td>
<td>{solicitacao.periodos.length}</td>
<td>{solicitacao.periodos.reduce((acc: number, p: any) => acc + p.diasCorridos, 0)}</td>
<td>
<div class={`badge badge-sm ${getStatusBadge(solicitacao.status)}`}>
{getStatusTexto(solicitacao.status)}
</div>
</td>
<td>
{#if solicitacao.status === "aguardando_aprovacao"}
<button
class="btn btn-sm btn-primary"
onclick={() => selecionarSolicitacao(solicitacao._id)}
>
Analisar
</button>
{:else}
<button
class="btn btn-sm btn-ghost"
onclick={() => selecionarSolicitacao(solicitacao._id)}
>
Detalhes
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
{/if}
</div>
<!-- Modal de Aprovação -->
{#if solicitacaoSelecionada}
<dialog class="modal modal-open">
<div class="modal-box max-w-4xl">
{#if authStore.usuario}
<AprovarFerias
solicitacao={solicitacaoSelecionada}
gestorId={authStore.usuario._id}
onSucesso={recarregar}
onCancelar={() => solicitacaoSelecionada = null}
/>
{/if}
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={() => solicitacaoSelecionada = null} aria-label="Fechar modal">Fechar</button>
</form>
</dialog>
{/if}
</main>

View File

@@ -79,6 +79,34 @@
},
],
},
{
categoria: "Gestão de Férias e Licenças",
descricao: "Controle de férias, atestados e licenças",
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" 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>`,
gradient: "from-purple-500/10 to-purple-600/20",
accentColor: "text-purple-600",
bgIcon: "bg-purple-500/20",
opcoes: [
{
nome: "Gestão de Férias",
descricao: "Controlar períodos de férias",
href: "/recursos-humanos/ferias",
icon: `<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="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>`,
},
{
nome: "Atestados & Licenças",
descricao: "Registrar atestados e licenças",
href: "/recursos-humanos/atestados-licencas",
icon: `<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>`,
},
],
},
];
</script>

View File

@@ -0,0 +1,91 @@
<script lang="ts">
import { goto } from "$app/navigation";
</script>
<main class="container mx-auto px-4 py-6 max-w-7xl">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href="/recursos-humanos" class="text-primary hover:underline">Recursos Humanos</a></li>
<li>Atestados & Licenças</li>
</ul>
</div>
<!-- Header -->
<div class="mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="p-3 bg-purple-500/20 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-primary">Atestados & Licenças</h1>
<p class="text-base-content/70">Registro de atestados médicos e licenças</p>
</div>
</div>
<button class="btn btn-ghost gap-2" onclick={() => goto("/recursos-humanos")}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Voltar
</button>
</div>
</div>
<!-- Alert de desenvolvimento -->
<div class="alert alert-info 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">Módulo em Desenvolvimento</h3>
<div class="text-sm">Esta funcionalidade está em desenvolvimento e estará disponível em breve.</div>
</div>
</div>
<!-- Preview do que virá -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6 opacity-60">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Registrar Atestado</h2>
<p class="text-sm text-base-content/70">Cadastre atestados médicos</p>
<div class="card-actions justify-end mt-4">
<button class="btn btn-sm btn-disabled">Em breve</button>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Registrar Licença</h2>
<p class="text-sm text-base-content/70">Cadastre licenças e afastamentos</p>
<div class="card-actions justify-end mt-4">
<button class="btn btn-sm btn-disabled">Em breve</button>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Histórico</h2>
<p class="text-sm text-base-content/70">Consulte histórico de atestados e licenças</p>
<div class="card-actions justify-end mt-4">
<button class="btn btn-sm btn-disabled">Em breve</button>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Estatísticas</h2>
<p class="text-sm text-base-content/70">Visualize estatísticas e relatórios</p>
<div class="card-actions justify-end mt-4">
<button class="btn btn-sm btn-disabled">Em breve</button>
</div>
</div>
</div>
</div>
</main>

View File

@@ -0,0 +1,285 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
// Buscar todas as solicitações (RH vê tudo)
const todasSolicitacoesQuery = useQuery(api.ferias.listarTodas, {});
const todosFuncionariosQuery = useQuery(api.funcionarios.getAll, {});
let filtroStatus = $state<string>("todos");
let filtroTime = $state<string>("todos");
let filtroBusca = $state("");
const solicitacoes = $derived(todasSolicitacoesQuery?.data || []);
const funcionarios = $derived(todosFuncionariosQuery?.data || []);
// Filtrar solicitações
const solicitacoesFiltradas = $derived(
solicitacoes.filter((s: any) => {
// Filtro de status
if (filtroStatus !== "todos" && s.status !== filtroStatus) return false;
// Filtro de time
if (filtroTime !== "todos" && s.time?._id !== filtroTime) return false;
// Filtro de busca
if (filtroBusca && !s.funcionario?.nome.toLowerCase().includes(filtroBusca.toLowerCase())) {
return false;
}
return true;
})
);
// Estatísticas
const stats = $derived({
total: solicitacoes.length,
aguardando: solicitacoes.filter((s: any) => s.status === "aguardando_aprovacao").length,
aprovadas: solicitacoes.filter((s: any) => s.status === "aprovado" || s.status === "data_ajustada_aprovada").length,
reprovadas: solicitacoes.filter((s: any) => s.status === "reprovado").length,
emFerias: funcionarios.filter((f: any) => f.statusFerias === "em_ferias").length,
});
// Times únicos para filtro
const timesDisponiveis = $derived(
Array.from(new Set(solicitacoes.map((s: any) => s.time).filter(Boolean)))
);
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",
aprovado: "Aprovado",
reprovado: "Reprovado",
data_ajustada_aprovada: "Ajustado",
};
return textos[status] || status;
}
function formatarData(dataISO: string) {
return new Date(dataISO).toLocaleDateString("pt-BR");
}
</script>
<main class="container mx-auto px-4 py-6 max-w-7xl">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href="/recursos-humanos" class="text-primary hover:underline">Recursos Humanos</a></li>
<li>Gestão de Férias</li>
</ul>
</div>
<!-- Header -->
<div class="mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="p-3 bg-purple-500/20 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 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>
<div>
<h1 class="text-3xl font-bold text-primary">Dashboard de Férias</h1>
<p class="text-base-content/70">Visão geral de todas as solicitações e funcionários</p>
</div>
</div>
<button class="btn btn-ghost gap-2" onclick={() => goto("/recursos-humanos")}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Voltar
</button>
</div>
</div>
<!-- Estatísticas -->
<div class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6">
<div class="stat bg-base-100 shadow-lg rounded-box border border-base-300">
<div class="stat-figure text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div class="stat-title">Total</div>
<div class="stat-value text-primary">{stats.total}</div>
<div class="stat-desc">Solicitações</div>
</div>
<div class="stat bg-base-100 shadow-lg rounded-box border border-warning/30">
<div class="stat-figure text-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" 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">Aguardando</div>
<div class="stat-value text-warning">{stats.aguardando}</div>
<div class="stat-desc">Pendentes</div>
</div>
<div class="stat bg-base-100 shadow-lg rounded-box border border-success/30">
<div class="stat-figure text-success">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" 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>
</div>
<div class="stat-title">Aprovadas</div>
<div class="stat-value text-success">{stats.aprovadas}</div>
<div class="stat-desc">Deferidas</div>
</div>
<div class="stat bg-base-100 shadow-lg rounded-box border border-error/30">
<div class="stat-figure text-error">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" 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>
</div>
<div class="stat-title">Reprovadas</div>
<div class="stat-value text-error">{stats.reprovadas}</div>
<div class="stat-desc">Indeferidas</div>
</div>
<div class="stat bg-gradient-to-br from-purple-500/10 to-purple-600/20 shadow-lg rounded-box border-2 border-purple-500/30">
<div class="stat-figure text-purple-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="stat-title">Em Férias</div>
<div class="stat-value text-purple-600">{stats.emFerias}</div>
<div class="stat-desc">Agora</div>
</div>
</div>
<!-- Filtros -->
<div class="card bg-base-100 shadow-lg mb-6">
<div class="card-body">
<h2 class="card-title text-lg mb-4">Filtros</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Busca -->
<div class="form-control">
<label class="label" for="busca">
<span class="label-text">Buscar Funcionário</span>
</label>
<input
id="busca"
type="text"
placeholder="Digite o nome..."
class="input input-bordered"
bind:value={filtroBusca}
/>
</div>
<!-- Filtro Status -->
<div class="form-control">
<label class="label" for="status">
<span class="label-text">Status</span>
</label>
<select id="status" class="select select-bordered" bind:value={filtroStatus}>
<option value="todos">Todos</option>
<option value="aguardando_aprovacao">Aguardando Aprovação</option>
<option value="aprovado">Aprovado</option>
<option value="reprovado">Reprovado</option>
<option value="data_ajustada_aprovada">Data Ajustada</option>
</select>
</div>
<!-- Filtro Time -->
<div class="form-control">
<label class="label" for="time">
<span class="label-text">Time</span>
</label>
<select id="time" class="select select-bordered" bind:value={filtroTime}>
<option value="todos">Todos os Times</option>
{#each timesDisponiveis as time}
{#if time}
<option value={time._id}>{time.nome}</option>
{/if}
{/each}
</select>
</div>
</div>
</div>
</div>
<!-- Lista de Solicitações -->
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-lg mb-4">
Solicitações ({solicitacoesFiltradas.length})
</h2>
{#if solicitacoesFiltradas.length === 0}
<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>Nenhuma solicitação encontrada com os filtros aplicados.</span>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Funcionário</th>
<th>Time</th>
<th>Ano</th>
<th>Períodos</th>
<th>Total Dias</th>
<th>Status</th>
<th>Solicitado em</th>
</tr>
</thead>
<tbody>
{#each solicitacoesFiltradas as solicitacao}
<tr>
<td>
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-10">
<span class="text-xs">{solicitacao.funcionario?.nome.substring(0, 2).toUpperCase()}</span>
</div>
</div>
<div>
<div class="font-bold">{solicitacao.funcionario?.nome}</div>
<div class="text-xs opacity-50">{solicitacao.funcionario?.matricula || "S/N"}</div>
</div>
</div>
</td>
<td>
{#if solicitacao.time}
<div class="badge badge-outline" style="border-color: {solicitacao.time.cor}">
{solicitacao.time.nome}
</div>
{:else}
<span class="text-base-content/50 text-xs">Sem time</span>
{/if}
</td>
<td>{solicitacao.anoReferencia}</td>
<td>{solicitacao.periodos.length} período(s)</td>
<td class="font-bold">{solicitacao.periodos.reduce((acc: number, p: any) => acc + p.diasCorridos, 0)} dias</td>
<td>
<div class={`badge ${getStatusBadge(solicitacao.status)}`}>
{getStatusTexto(solicitacao.status)}
</div>
</td>
<td class="text-xs">{new Date(solicitacao._creationTime).toLocaleDateString("pt-BR")}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
</main>

View File

@@ -10,8 +10,6 @@
let list: Array<any> = [];
let filtered: Array<any> = [];
let selectedId: string | null = null;
let deletingId: string | null = null;
let toDelete: { id: string; nome: string } | null = null;
let openMenuId: string | null = null;
let funcionarioParaImprimir: any = null;
@@ -42,15 +40,6 @@
if (selectedId) goto(`/recursos-humanos/funcionarios/${selectedId}/editar`);
}
function openDeleteModal(id: string, nome: string) {
toDelete = { id, nome };
(document.getElementById("delete_modal_func") as HTMLDialogElement)?.showModal();
}
function closeDeleteModal() {
toDelete = null;
(document.getElementById("delete_modal_func") as HTMLDialogElement)?.close();
}
async function openPrintModal(funcionarioId: string) {
try {
const data = await client.query(api.funcionarios.getFichaCompleta, {
@@ -62,17 +51,6 @@
alert("Erro ao carregar dados para impressão");
}
}
async function confirmDelete() {
if (!toDelete) return;
try {
deletingId = toDelete.id;
await client.mutation(api.funcionarios.remove, { id: toDelete.id } as any);
closeDeleteModal();
await load();
} finally {
deletingId = null;
}
}
function navCadastro() { goto("/recursos-humanos/funcionarios/cadastro"); }
@@ -231,7 +209,6 @@
<li><a href={`/recursos-humanos/funcionarios/${f._id}/editar`}>Editar</a></li>
<li><a href={`/recursos-humanos/funcionarios/${f._id}/documentos`}>Ver Documentos</a></li>
<li><button onclick={() => openPrintModal(f._id)}>Imprimir Ficha</button></li>
<li class="border-t mt-1 pt-1"><button class="text-error" onclick={() => openDeleteModal(f._id, f.nome)}>Excluir</button></li>
</ul>
</div>
</td>
@@ -249,36 +226,6 @@
Exibindo {filtered.length} de {list.length} funcionário(s)
</div>
<!-- Modal de Confirmação de Exclusão -->
<dialog id="delete_modal_func" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">Confirmar Exclusão</h3>
<div class="alert alert-warning mb-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>Esta ação não pode ser desfeita!</span>
</div>
{#if toDelete}
<p class="py-2">Tem certeza que deseja excluir o funcionário <strong class="text-error">{toDelete.nome}</strong>?</p>
{/if}
<div class="modal-action">
<form method="dialog" class="flex gap-2">
<button class="btn btn-ghost" onclick={closeDeleteModal} type="button">Cancelar</button>
<button class="btn btn-error" onclick={confirmDelete} disabled={deletingId !== null} type="button">
{#if deletingId}
<span class="loading loading-spinner loading-sm"></span>
Excluindo...
{:else}
Confirmar Exclusão
{/if}
</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Modal de Impressão -->
{#if funcionarioParaImprimir}
<PrintModal

View File

@@ -17,9 +17,11 @@
let funcionario = $state<any>(null);
let simbolo = $state<any>(null);
let cursos = $state<any[]>([]);
let documentosUrls = $state<Record<string, string | null>>({});
let loading = $state(true);
let showPrintModal = $state(false);
let showPrintFinanceiro = $state(false);
async function load() {
try {
@@ -35,6 +37,7 @@
funcionario = data;
simbolo = data.simbolo;
cursos = data.cursos || [];
// Carregar URLs dos documentos
try {
@@ -126,12 +129,87 @@
</svg>
Imprimir Ficha
</button>
<button class="btn btn-info gap-2" onclick={() => showPrintFinanceiro = 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 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Imprimir Dados Financeiros
</button>
</div>
</div>
</div>
<!-- Dados Financeiros - Destaque -->
{#if simbolo}
<div class="card bg-gradient-to-br from-success/10 to-success/20 shadow-xl mb-6 border border-success/30">
<div class="card-body">
<h3 class="card-title text-xl border-b pb-3 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Dados Financeiros
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="stat bg-base-100/50 rounded-lg p-4">
<div class="stat-title text-xs">Símbolo</div>
<div class="stat-value text-2xl">{simbolo.nome}</div>
<div class="stat-desc text-xs">{simbolo.tipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'}</div>
</div>
{#if funcionario.simboloTipo === 'cargo_comissionado'}
<div class="stat bg-base-100/50 rounded-lg p-4">
<div class="stat-title text-xs">Vencimento</div>
<div class="stat-value text-2xl text-info">R$ {simbolo.vencValor}</div>
<div class="stat-desc text-xs">Valor base</div>
</div>
<div class="stat bg-base-100/50 rounded-lg p-4">
<div class="stat-title text-xs">Representação</div>
<div class="stat-value text-2xl text-warning">R$ {simbolo.repValor}</div>
<div class="stat-desc text-xs">Adicional</div>
</div>
{/if}
<div class="stat bg-success/20 rounded-lg p-4 border-2 border-success/40">
<div class="stat-title text-xs font-bold">Total</div>
<div class="stat-value text-3xl text-success">R$ {simbolo.valor}</div>
<div class="stat-desc text-xs">Remuneração total</div>
</div>
</div>
</div>
</div>
{/if}
<!-- Status de Férias -->
<div class="card bg-gradient-to-br from-purple-500/10 to-purple-600/20 shadow-xl mb-6 border border-purple-500/30">
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-3 bg-purple-500/20 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 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>
<div>
<h3 class="font-bold text-lg">Status Atual</h3>
<div class="flex items-center gap-2 mt-1">
{#if funcionario.statusFerias === "em_ferias"}
<div class="badge badge-warning badge-lg">🏖️ Em Férias</div>
{:else}
<div class="badge badge-success badge-lg">✅ Ativo</div>
{/if}
</div>
</div>
</div>
<a href="/recursos-humanos/ferias" class="btn btn-primary btn-sm gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Gerenciar Férias
</a>
</div>
</div>
</div>
<!-- Grid de Cards -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Coluna 1: Dados Pessoais -->
<div class="space-y-6">
<!-- Informações Pessoais -->
@@ -196,8 +274,45 @@
{/if}
</div>
<!-- Coluna 2: Documentos e Formação -->
<!-- Coluna 2: Cargo, Formação e Cursos -->
<div class="space-y-6">
<!-- Cargo e Vínculo -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-lg border-b pb-2 mb-3">Cargo e Vínculo</h3>
<div class="space-y-2 text-sm">
<div><span class="font-semibold">Tipo:</span> {funcionario.simboloTipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'}</div>
{#if simbolo}
<div><span class="font-semibold">Símbolo:</span> {simbolo.nome}</div>
<div class="text-xs text-base-content/70">{simbolo.descricao}</div>
{/if}
{#if funcionario.descricaoCargo}
<div class="mt-2"><span class="font-semibold">Descrição:</span> {funcionario.descricaoCargo}</div>
{/if}
{#if funcionario.admissaoData}
<div class="mt-2"><span class="font-semibold">Data Admissão:</span> {funcionario.admissaoData}</div>
{/if}
{#if funcionario.nomeacaoPortaria}
<div><span class="font-semibold">Portaria:</span> {funcionario.nomeacaoPortaria}</div>
{/if}
{#if funcionario.nomeacaoData}
<div><span class="font-semibold">Data Nomeação:</span> {funcionario.nomeacaoData}</div>
{/if}
{#if funcionario.nomeacaoDOE}
<div><span class="font-semibold">DOE:</span> {funcionario.nomeacaoDOE}</div>
{/if}
{#if funcionario.pertenceOrgaoPublico}
<div class="mt-2"><span class="font-semibold">Pertence Órgão Público:</span> Sim</div>
{#if funcionario.orgaoOrigem}
<div><span class="font-semibold">Órgão Origem:</span> {funcionario.orgaoOrigem}</div>
{/if}
{/if}
{#if funcionario.aposentado && funcionario.aposentado !== 'nao'}
<div><span class="font-semibold">Aposentado:</span> {getLabelFromOptions(funcionario.aposentado, APOSENTADO_OPTIONS)}</div>
{/if}
</div>
</div>
</div>
<!-- Documentos Pessoais -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
@@ -253,6 +368,48 @@
</div>
{/if}
<!-- Cursos e Treinamentos -->
{#if cursos && cursos.length > 0}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-lg border-b pb-2 mb-3">
<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 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Cursos e Treinamentos
</h3>
<div class="space-y-3">
{#each cursos as curso}
<div class="flex items-start gap-3 p-3 bg-base-200 rounded-lg">
<div class="flex-1">
<p class="font-semibold text-sm">{curso.descricao}</p>
<p class="text-xs text-base-content/70 mt-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 inline mr-1" 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>
{curso.data}
</p>
</div>
{#if curso.certificadoUrl}
<a
href={curso.certificadoUrl}
target="_blank"
rel="noopener noreferrer"
class="btn btn-xs btn-primary gap-1"
>
<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 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>
Certificado
</a>
{/if}
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Saúde -->
{#if funcionario.grupoSanguineo || funcionario.fatorRH}
<div class="card bg-base-100 shadow-xl">
@@ -280,47 +437,6 @@
</div>
</div>
</div>
</div>
<!-- Coluna 3: Cargo e Bancário -->
<div class="space-y-6">
<!-- Cargo e Vínculo -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-lg border-b pb-2 mb-3">Cargo e Vínculo</h3>
<div class="space-y-2 text-sm">
<div><span class="font-semibold">Tipo:</span> {funcionario.simboloTipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'}</div>
{#if simbolo}
<div><span class="font-semibold">Símbolo:</span> {simbolo.nome}</div>
<div class="text-xs text-base-content/70">{simbolo.descricao}</div>
{/if}
{#if funcionario.descricaoCargo}
<div class="mt-2"><span class="font-semibold">Descrição:</span> {funcionario.descricaoCargo}</div>
{/if}
{#if funcionario.admissaoData}
<div class="mt-2"><span class="font-semibold">Data Admissão:</span> {funcionario.admissaoData}</div>
{/if}
{#if funcionario.nomeacaoPortaria}
<div><span class="font-semibold">Portaria:</span> {funcionario.nomeacaoPortaria}</div>
{/if}
{#if funcionario.nomeacaoData}
<div><span class="font-semibold">Data Nomeação:</span> {funcionario.nomeacaoData}</div>
{/if}
{#if funcionario.nomeacaoDOE}
<div><span class="font-semibold">DOE:</span> {funcionario.nomeacaoDOE}</div>
{/if}
{#if funcionario.pertenceOrgaoPublico}
<div class="mt-2"><span class="font-semibold">Pertence Órgão Público:</span> Sim</div>
{#if funcionario.orgaoOrigem}
<div><span class="font-semibold">Órgão Origem:</span> {funcionario.orgaoOrigem}</div>
{/if}
{/if}
{#if funcionario.aposentado && funcionario.aposentado !== 'nao'}
<div><span class="font-semibold">Aposentado:</span> {getLabelFromOptions(funcionario.aposentado, APOSENTADO_OPTIONS)}</div>
{/if}
</div>
</div>
</div>
<!-- Endereço -->
<div class="card bg-base-100 shadow-xl">
@@ -431,4 +547,103 @@
onClose={() => showPrintModal = false}
/>
{/if}
<!-- Modal de Impressão Dados Financeiros -->
{#if showPrintFinanceiro && simbolo}
<dialog class="modal modal-open">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-2xl mb-6 border-b pb-3">Dados Financeiros - {funcionario.nome}</h3>
<div class="space-y-4 print:space-y-2" id="dados-financeiros-print">
<!-- Informações Básicas -->
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm font-semibold text-base-content/70">Nome</p>
<p class="text-lg">{funcionario.nome}</p>
</div>
<div>
<p class="text-sm font-semibold text-base-content/70">Matrícula</p>
<p class="text-lg">{funcionario.matricula || 'N/A'}</p>
</div>
<div>
<p class="text-sm font-semibold text-base-content/70">CPF</p>
<p class="text-lg">{maskCPF(funcionario.cpf)}</p>
</div>
<div>
<p class="text-sm font-semibold text-base-content/70">Data Admissão</p>
<p class="text-lg">{funcionario.admissaoData || 'N/A'}</p>
</div>
</div>
<div class="divider"></div>
<!-- Dados Financeiros -->
<div>
<h4 class="font-bold text-lg mb-3">Remuneração</h4>
<div class="space-y-2">
<div class="flex justify-between p-2 bg-base-200 rounded">
<span class="font-semibold">Símbolo:</span>
<span>{simbolo.nome}</span>
</div>
<div class="flex justify-between p-2 bg-base-200 rounded">
<span class="font-semibold">Tipo:</span>
<span>{simbolo.tipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'}</span>
</div>
{#if funcionario.simboloTipo === 'cargo_comissionado'}
<div class="flex justify-between p-2 bg-info/10 rounded">
<span class="font-semibold">Vencimento:</span>
<span class="text-info font-bold">R$ {simbolo.vencValor}</span>
</div>
<div class="flex justify-between p-2 bg-warning/10 rounded">
<span class="font-semibold">Representação:</span>
<span class="text-warning font-bold">R$ {simbolo.repValor}</span>
</div>
{/if}
<div class="flex justify-between p-3 bg-success/20 rounded border-2 border-success/40">
<span class="font-bold text-lg">TOTAL:</span>
<span class="text-success font-bold text-2xl">R$ {simbolo.valor}</span>
</div>
</div>
</div>
{#if funcionario.contaBradescoNumero}
<div class="divider"></div>
<div>
<h4 class="font-bold text-lg mb-3">Dados Bancários</h4>
<div class="space-y-2">
<div class="flex justify-between p-2 bg-base-200 rounded">
<span class="font-semibold">Banco:</span>
<span>Bradesco</span>
</div>
<div class="flex justify-between p-2 bg-base-200 rounded">
<span class="font-semibold">Agência:</span>
<span>{funcionario.contaBradescoAgencia || 'N/A'}</span>
</div>
<div class="flex justify-between p-2 bg-base-200 rounded">
<span class="font-semibold">Conta:</span>
<span>{funcionario.contaBradescoNumero}{funcionario.contaBradescoDV ? `-${funcionario.contaBradescoDV}` : ''}</span>
</div>
</div>
</div>
{/if}
</div>
<div class="modal-action">
<button
class="btn btn-primary gap-2"
onclick={() => window.print()}
>
<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="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
Imprimir
</button>
<button class="btn btn-ghost" onclick={() => showPrintFinanceiro = false}>Fechar</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={() => showPrintFinanceiro = false} aria-label="Fechar modal">Fechar</button>
</form>
</dialog>
{/if}
{/if}

View File

@@ -93,6 +93,25 @@
// Documentos (Storage IDs)
let documentosStorage: Record<string, string | undefined> = $state({});
// Cursos e Treinamentos
interface Curso {
_id?: string;
id: string;
descricao: string;
data: string;
certificadoId?: string;
arquivo?: File;
marcadoParaExcluir?: boolean;
}
let cursos = $state<Curso[]>([]);
let mostrarFormularioCurso = $state(false);
let cursoAtual = $state<Curso>({
id: crypto.randomUUID(),
descricao: "",
data: "",
});
async function loadSimbolos() {
const list = await client.query(api.simbolos.getAll, {} as any);
simbolos = list.map((s: any) => ({
@@ -170,6 +189,22 @@
documentosStorage[doc.campo] = storageId;
}
});
// Carregar cursos
try {
const cursosData = await client.query(api.cursos.listarPorFuncionario, {
funcionarioId: funcionarioId as any,
});
cursos = cursosData.map((c: any) => ({
_id: c._id,
id: c._id,
descricao: c.descricao,
data: c.data,
certificadoId: c.certificadoId,
}));
} catch (error) {
console.error("Erro ao carregar cursos:", error);
}
} catch (error) {
console.error("Erro ao carregar funcionário:", error);
notice = { kind: "error", text: "Erro ao carregar dados do funcionário" };
@@ -194,6 +229,51 @@
} catch {}
}
// Funções de Cursos
function adicionarCurso() {
if (!cursoAtual.descricao.trim() || !cursoAtual.data.trim()) {
notice = { kind: "error", text: "Preencha a descrição e data do curso" };
return;
}
if (cursos.filter(c => !c.marcadoParaExcluir).length >= 7) {
notice = { kind: "error", text: "Máximo de 7 cursos permitidos" };
return;
}
cursos.push({ ...cursoAtual });
cursoAtual = {
id: crypto.randomUUID(),
descricao: "",
data: "",
};
mostrarFormularioCurso = false;
}
function removerCurso(id: string) {
const curso = cursos.find(c => c.id === id);
if (curso && curso._id) {
// Marcar para excluir se já existe no banco
curso.marcadoParaExcluir = true;
} else {
// Remover diretamente se é novo
cursos = cursos.filter(c => c.id !== id);
}
}
async function uploadCertificado(file: File): Promise<string> {
const uploadUrl = await client.mutation(api.documentos.generateUploadUrl, {});
const result = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
const { storageId } = await result.json();
return storageId;
}
async function handleDocumentoUpload(campo: string, file: File) {
try {
const uploadUrl = await client.mutation(api.documentos.generateUploadUrl, {});
@@ -299,6 +379,45 @@
};
await client.mutation(api.funcionarios.update, { id: funcionarioId as any, ...payload as any });
// Salvar cursos
try {
// Excluir cursos marcados
for (const curso of cursos.filter(c => c.marcadoParaExcluir && c._id)) {
await client.mutation(api.cursos.excluir, { id: curso._id as any });
}
// Adicionar/atualizar cursos
for (const curso of cursos.filter(c => !c.marcadoParaExcluir)) {
let certificadoId = curso.certificadoId;
// Upload de certificado se houver arquivo novo
if (curso.arquivo) {
certificadoId = await uploadCertificado(curso.arquivo);
}
if (curso._id) {
// Atualizar curso existente
await client.mutation(api.cursos.atualizar, {
id: curso._id as any,
descricao: curso.descricao,
data: curso.data,
certificadoId: certificadoId as any,
});
} else {
// Criar novo curso
await client.mutation(api.cursos.criar, {
funcionarioId: funcionarioId as any,
descricao: curso.descricao,
data: curso.data,
certificadoId: certificadoId as any,
});
}
}
} catch (error) {
console.error("Erro ao salvar cursos:", error);
}
notice = { kind: "success", text: "Funcionário atualizado com sucesso!" };
setTimeout(() => goto("/recursos-humanos/funcionarios"), 600);
} catch (e: any) {
@@ -1254,6 +1373,122 @@
</div>
</div>
<!-- Card 7.5: Cursos e Treinamentos -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body space-y-4">
<h2 class="card-title text-xl border-b pb-3">
<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 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Cursos e Treinamentos
</h2>
<p class="text-sm text-base-content/70">
Gerencie cursos e treinamentos do funcionário (até 7 cursos)
</p>
{#if cursos.filter(c => !c.marcadoParaExcluir).length > 0}
<div class="space-y-2">
<h3 class="font-semibold text-sm">Cursos cadastrados ({cursos.filter(c => !c.marcadoParaExcluir).length}/7)</h3>
{#each cursos.filter(c => !c.marcadoParaExcluir) as curso}
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg">
<div class="flex-1">
<p class="font-semibold text-sm">{curso.descricao}</p>
<p class="text-xs text-base-content/70">{curso.data}</p>
{#if curso.certificadoId}
<p class="text-xs text-success">✓ Com certificado</p>
{/if}
</div>
<button
type="button"
class="btn btn-sm btn-error btn-square"
aria-label="Remover curso"
onclick={() => removerCurso(curso.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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
{#if cursos.filter(c => !c.marcadoParaExcluir).length < 7}
<div class="collapse collapse-arrow border border-base-300 bg-base-200">
<input type="checkbox" bind:checked={mostrarFormularioCurso} />
<div class="collapse-title font-medium">
Adicionar Curso/Treinamento
</div>
<div class="collapse-content">
<div class="space-y-3 pt-2">
<div class="form-control">
<label class="label" for="curso-descricao-edit">
<span class="label-text font-medium">Descrição do Curso</span>
</label>
<input
id="curso-descricao-edit"
type="text"
class="input input-bordered w-full"
bind:value={cursoAtual.descricao}
placeholder="Ex: Gestão de Projetos"
/>
</div>
<div class="form-control">
<label class="label" for="curso-data-edit">
<span class="label-text font-medium">Data de Conclusão</span>
</label>
<input
id="curso-data-edit"
type="text"
class="input input-bordered w-full"
bind:value={cursoAtual.data}
placeholder="Ex: 01/2024"
onchange={(e) => cursoAtual.data = maskDate(e.currentTarget.value)}
/>
</div>
<div class="form-control">
<label class="label" for="curso-certificado-edit">
<span class="label-text font-medium">Certificado/Diploma (opcional)</span>
</label>
<input
id="curso-certificado-edit"
type="file"
class="file-input file-input-bordered w-full"
accept=".pdf,.jpg,.jpeg,.png"
onchange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) cursoAtual.arquivo = file;
}}
/>
</div>
<button
type="button"
class="btn btn-primary btn-sm gap-2 mt-2"
onclick={adicionarCurso}
>
<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 à Lista
</button>
</div>
</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>Limite de 7 cursos atingido</span>
</div>
{/if}
</div>
</div>
<!-- Card 8: Ações -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">

View File

@@ -89,6 +89,46 @@
// Documentos (Storage IDs)
let documentosStorage: Record<string, string | undefined> = $state({});
// Cursos e Treinamentos
let cursos = $state<Array<{
id: string;
descricao: string;
data: string;
certificadoId?: string;
}>>([]);
let mostrarFormularioCurso = $state(false);
let cursoAtual = $state({ descricao: "", data: "", arquivo: null as File | null });
function adicionarCurso() {
if (!cursoAtual.descricao.trim() || !cursoAtual.data.trim()) {
alert("Preencha a descrição e a data do curso");
return;
}
cursos.push({
id: crypto.randomUUID(),
descricao: cursoAtual.descricao,
data: cursoAtual.data,
certificadoId: undefined
});
cursoAtual = { descricao: "", data: "", arquivo: null };
}
function removerCurso(id: string) {
cursos = cursos.filter(c => c.id !== id);
}
async function uploadCertificado(file: File): Promise<string> {
const storageId = await client.mutation(api.documentos.generateUploadUrl, {});
const uploadUrl = await client.mutation(api.documentos.generateUploadUrl, {});
const response = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
const result = await response.json();
return result.storageId;
}
async function loadSimbolos() {
const list = await client.query(api.simbolos.getAll, {} as any);
simbolos = list.map((s: any) => ({
@@ -140,7 +180,7 @@
async function handleSubmit() {
// Validação básica
if (!nome || !matricula || !cpf || !rg || !nascimento || !email || !telefone) {
if (!nome || !cpf || !rg || !nascimento || !email || !telefone) {
notice = { kind: "error", text: "Preencha todos os campos obrigatórios" };
return;
}
@@ -165,7 +205,7 @@
const payload = {
nome,
matricula,
matricula: matricula.trim() || undefined,
cpf: onlyDigits(cpf),
rg: onlyDigits(rg),
nascimento,
@@ -229,7 +269,28 @@
),
};
await client.mutation(api.funcionarios.create, payload as any);
const novoFuncionarioId = await client.mutation(api.funcionarios.create, payload as any);
// Salvar cursos, se houver
for (const curso of cursos) {
let certificadoId = curso.certificadoId;
// Se houver arquivo para upload, fazer o upload
if (cursoAtual.arquivo && curso.id === cursos[cursos.length - 1].id) {
try {
certificadoId = await uploadCertificado(cursoAtual.arquivo);
} catch (err) {
console.error("Erro ao fazer upload do certificado:", err);
}
}
await client.mutation(api.cursos.criar, {
funcionarioId: novoFuncionarioId,
descricao: curso.descricao,
data: curso.data,
certificadoId: certificadoId as any,
});
}
notice = { kind: "success", text: "Funcionário cadastrado com sucesso!" };
setTimeout(() => goto("/recursos-humanos/funcionarios"), 600);
} catch (e: any) {
@@ -327,14 +388,14 @@
<!-- Matrícula -->
<div class="form-control">
<label class="label" for="matricula">
<span class="label-text font-medium">Matrícula <span class="text-error">*</span></span>
<span class="label-text font-medium">Matrícula <span class="text-base-content/50 text-xs">(opcional)</span></span>
</label>
<input
id="matricula"
type="text"
class="input input-bordered w-full"
bind:value={matricula}
required
placeholder="Deixe em branco se não tiver"
/>
</div>
@@ -768,6 +829,121 @@
</div>
</div>
<!-- Card 3.5: Cursos e Treinamentos -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body space-y-4">
<h2 class="card-title text-xl border-b pb-3">
<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 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Cursos e Treinamentos
</h2>
<p class="text-sm text-base-content/70">
Adicione até 7 cursos ou treinamentos realizados pelo funcionário (opcional)
</p>
<!-- Lista de cursos adicionados -->
{#if cursos.length > 0}
<div class="space-y-2">
<h3 class="font-semibold text-sm">Cursos adicionados ({cursos.length}/7)</h3>
{#each cursos as curso}
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg">
<div class="flex-1">
<p class="font-semibold text-sm">{curso.descricao}</p>
<p class="text-xs text-base-content/70">{curso.data}</p>
</div>
<button
type="button"
class="btn btn-sm btn-error btn-square"
aria-label="Remover curso"
onclick={() => removerCurso(curso.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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
<!-- Formulário para adicionar curso -->
{#if cursos.length < 7}
<div class="collapse collapse-arrow border border-base-300 bg-base-200">
<input type="checkbox" bind:checked={mostrarFormularioCurso} />
<div class="collapse-title font-medium">
Adicionar Curso/Treinamento
</div>
<div class="collapse-content">
<div class="space-y-3 pt-2">
<div class="form-control">
<label class="label" for="curso-descricao">
<span class="label-text font-medium">Descrição do Curso</span>
</label>
<input
id="curso-descricao"
type="text"
class="input input-bordered w-full"
bind:value={cursoAtual.descricao}
placeholder="Ex: Gestão de Projetos"
/>
</div>
<div class="form-control">
<label class="label" for="curso-data">
<span class="label-text font-medium">Data de Conclusão</span>
</label>
<input
id="curso-data"
type="text"
class="input input-bordered w-full"
bind:value={cursoAtual.data}
placeholder="Ex: 01/2024"
onchange={(e) => cursoAtual.data = maskDate(e.currentTarget.value)}
/>
</div>
<div class="form-control">
<label class="label" for="curso-certificado">
<span class="label-text font-medium">Certificado/Diploma (opcional)</span>
</label>
<input
id="curso-certificado"
type="file"
class="file-input file-input-bordered w-full"
accept=".pdf,.jpg,.jpeg,.png"
onchange={(e) => {
const file = e.currentTarget.files?.[0];
if (file) cursoAtual.arquivo = file;
}}
/>
</div>
<button
type="button"
class="btn btn-primary btn-sm gap-2 mt-2"
onclick={adicionarCurso}
>
<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 à Lista
</button>
</div>
</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>Limite de 7 cursos atingido</span>
</div>
{/if}
</div>
</div>
<!-- Card 4: Endereço e Contato -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body space-y-4">

View File

@@ -0,0 +1,505 @@
<script lang="ts">
import { useConvexClient, useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { authStore } from "$lib/stores/auth.svelte";
import { goto } from "$app/navigation";
const client = useConvexClient();
// Queries
const timesQuery = useQuery(api.times.listar, {});
const usuariosQuery = useQuery(api.usuarios.listar, {});
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const times = $derived(timesQuery?.data || []);
const usuarios = $derived(usuariosQuery?.data || []);
const funcionarios = $derived(funcionariosQuery?.data || []);
// Estados
let modoEdicao = $state(false);
let timeEmEdicao = $state<any>(null);
let mostrarModalMembros = $state(false);
let timeParaMembros = $state<any>(null);
let mostrarConfirmacaoExclusao = $state(false);
let timeParaExcluir = $state<any>(null);
let processando = $state(false);
// Form
let formNome = $state("");
let formDescricao = $state("");
let formGestorId = $state("");
let formCor = $state("#3B82F6");
// Membros
let membrosDisponiveis = $derived(
funcionarios.filter((f: any) => {
// Verificar se o funcionário já está em algum time ativo
const jaNaEquipe = timeParaMembros?.membros?.some((m: any) => m.funcionario?._id === f._id);
return !jaNaEquipe;
})
);
// Cores predefinidas
const coresDisponiveis = [
"#3B82F6", // Blue
"#10B981", // Green
"#F59E0B", // Yellow
"#EF4444", // Red
"#8B5CF6", // Purple
"#EC4899", // Pink
"#14B8A6", // Teal
"#F97316", // Orange
];
function novoTime() {
modoEdicao = true;
timeEmEdicao = null;
formNome = "";
formDescricao = "";
formGestorId = "";
formCor = coresDisponiveis[Math.floor(Math.random() * coresDisponiveis.length)];
}
function editarTime(time: any) {
modoEdicao = true;
timeEmEdicao = time;
formNome = time.nome;
formDescricao = time.descricao || "";
formGestorId = time.gestorId;
formCor = time.cor || "#3B82F6";
}
function cancelarEdicao() {
modoEdicao = false;
timeEmEdicao = null;
formNome = "";
formDescricao = "";
formGestorId = "";
formCor = "#3B82F6";
}
async function salvarTime() {
if (!formNome.trim() || !formGestorId) {
alert("Preencha todos os campos obrigatórios!");
return;
}
processando = true;
try {
if (timeEmEdicao) {
await client.mutation(api.times.atualizar, {
id: timeEmEdicao._id,
nome: formNome,
descricao: formDescricao || undefined,
gestorId: formGestorId as any,
cor: formCor,
});
} else {
await client.mutation(api.times.criar, {
nome: formNome,
descricao: formDescricao || undefined,
gestorId: formGestorId as any,
cor: formCor,
});
}
cancelarEdicao();
} catch (e: any) {
alert("Erro ao salvar: " + (e.message || e));
} finally {
processando = false;
}
}
function confirmarExclusao(time: any) {
timeParaExcluir = time;
mostrarConfirmacaoExclusao = true;
}
async function excluirTime() {
if (!timeParaExcluir) return;
processando = true;
try {
await client.mutation(api.times.desativar, { id: timeParaExcluir._id });
mostrarConfirmacaoExclusao = false;
timeParaExcluir = null;
} catch (e: any) {
alert("Erro ao excluir: " + (e.message || e));
} finally {
processando = false;
}
}
async function abrirGerenciarMembros(time: any) {
const detalhes = await client.query(api.times.obterPorId, { id: time._id });
timeParaMembros = detalhes;
mostrarModalMembros = true;
}
async function adicionarMembro(funcionarioId: string) {
if (!timeParaMembros) return;
processando = true;
try {
await client.mutation(api.times.adicionarMembro, {
timeId: timeParaMembros._id,
funcionarioId: funcionarioId as any,
});
// Recarregar detalhes do time
const detalhes = await client.query(api.times.obterPorId, { id: timeParaMembros._id });
timeParaMembros = detalhes;
} catch (e: any) {
alert("Erro: " + (e.message || e));
} finally {
processando = false;
}
}
async function removerMembro(membroId: string) {
if (!confirm("Deseja realmente remover este membro do time?")) return;
processando = true;
try {
await client.mutation(api.times.removerMembro, { membroId: membroId as any });
// Recarregar detalhes do time
const detalhes = await client.query(api.times.obterPorId, { id: timeParaMembros._id });
timeParaMembros = detalhes;
} catch (e: any) {
alert("Erro: " + (e.message || e));
} finally {
processando = false;
}
}
function fecharModalMembros() {
mostrarModalMembros = false;
timeParaMembros = null;
}
</script>
<main class="container mx-auto px-4 py-6 max-w-7xl">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href="/ti" class="text-primary hover:underline">Tecnologia da Informação</a></li>
<li>Gestão de Times</li>
</ul>
</div>
<!-- Header -->
<div class="mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="p-3 bg-blue-500/20 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-blue-600" 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>
<h1 class="text-3xl font-bold text-primary">Gestão de Times</h1>
<p class="text-base-content/70">Organize funcionários em equipes e defina gestores</p>
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-ghost gap-2" onclick={() => goto("/ti")}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Voltar
</button>
<button class="btn btn-primary gap-2" onclick={novoTime}>
<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 Time
</button>
</div>
</div>
</div>
<!-- Formulário de Edição -->
{#if modoEdicao}
<div class="card bg-gradient-to-br from-primary/10 to-primary/5 shadow-xl mb-6 border-2 border-primary/20">
<div class="card-body">
<h2 class="card-title text-xl mb-4">
{timeEmEdicao ? "Editar Time" : "Novo Time"}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="nome">
<span class="label-text font-semibold">Nome do Time *</span>
</label>
<input
id="nome"
type="text"
class="input input-bordered"
bind:value={formNome}
placeholder="Ex: Equipe Administrativa"
/>
</div>
<div class="form-control">
<label class="label" for="gestor">
<span class="label-text font-semibold">Gestor *</span>
</label>
<select id="gestor" class="select select-bordered" bind:value={formGestorId}>
<option value="">Selecione um gestor</option>
{#each usuarios as usuario}
<option value={usuario._id}>{usuario.nome}</option>
{/each}
</select>
</div>
<div class="form-control md:col-span-2">
<label class="label" for="descricao">
<span class="label-text font-semibold">Descrição</span>
</label>
<textarea
id="descricao"
class="textarea textarea-bordered"
bind:value={formDescricao}
placeholder="Descrição opcional do time"
rows="2"
></textarea>
</div>
<div class="form-control">
<label class="label" for="cor">
<span class="label-text font-semibold">Cor do Time</span>
</label>
<div class="flex gap-2 flex-wrap">
{#each coresDisponiveis as cor}
<button
type="button"
class="w-10 h-10 rounded-lg border-2 transition-all hover:scale-110"
style="background-color: {cor}; border-color: {formCor === cor ? '#000' : cor}"
onclick={() => formCor = cor}
aria-label="Selecionar cor"
></button>
{/each}
</div>
</div>
</div>
<div class="card-actions justify-end mt-6">
<button class="btn btn-ghost" onclick={cancelarEdicao} disabled={processando}>
Cancelar
</button>
<button class="btn btn-primary" onclick={salvarTime} disabled={processando}>
{processando ? "Salvando..." : "Salvar"}
</button>
</div>
</div>
</div>
{/if}
<!-- Lista de Times -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each times as time}
{#if time.ativo}
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all border-l-4" style="border-color: {time.cor}">
<div class="card-body">
<div class="flex items-start justify-between">
<h2 class="card-title text-lg">{time.nome}</h2>
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-sm btn-square" aria-label="Menu do time">
<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 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-xl bg-base-100 rounded-box w-52 border border-base-300">
<li>
<button type="button" onclick={() => editarTime(time)}>
<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>
Editar
</button>
</li>
<li>
<button type="button" onclick={() => abrirGerenciarMembros(time)}>
<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 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
Gerenciar Membros
</button>
</li>
<li>
<button type="button" onclick={() => confirmarExclusao(time)} class="text-error">
<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>
Desativar
</button>
</li>
</ul>
</div>
</div>
<p class="text-sm text-base-content/70 mb-3">{time.descricao || "Sem descrição"}</p>
<div class="divider my-2"></div>
<div class="space-y-2">
<div class="flex items-center gap-2 text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span><strong>Gestor:</strong> {time.gestor?.nome}</span>
</div>
<div class="flex items-center gap-2 text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span><strong>Membros:</strong> {time.totalMembros || 0}</span>
</div>
</div>
</div>
</div>
{/if}
{/each}
{#if times.filter((t: any) => t.ativo).length === 0}
<div class="col-span-full">
<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 time cadastrado. Clique em "Novo Time" para começar.</span>
</div>
</div>
{/if}
</div>
<!-- Modal de Gerenciar Membros -->
{#if mostrarModalMembros && timeParaMembros}
<dialog class="modal modal-open">
<div class="modal-box max-w-4xl">
<h3 class="font-bold text-2xl mb-4 flex items-center gap-2">
<div class="w-3 h-3 rounded-full" style="background-color: {timeParaMembros.cor}"></div>
{timeParaMembros.nome}
</h3>
<!-- Membros Atuais -->
<div class="mb-6">
<h4 class="font-bold text-lg mb-3">Membros Atuais ({timeParaMembros.membros?.length || 0})</h4>
{#if timeParaMembros.membros && timeParaMembros.membros.length > 0}
<div class="space-y-2 max-h-60 overflow-y-auto">
{#each timeParaMembros.membros as membro}
<div class="flex items-center justify-between p-3 bg-base-200 rounded-lg">
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-10">
<span class="text-xs">{membro.funcionario?.nome.substring(0, 2).toUpperCase()}</span>
</div>
</div>
<div>
<div class="font-semibold">{membro.funcionario?.nome}</div>
<div class="text-xs text-base-content/50">
Desde {new Date(membro.dataEntrada).toLocaleDateString("pt-BR")}
</div>
</div>
</div>
<button
class="btn btn-sm btn-error btn-square"
onclick={() => removerMembro(membro._id)}
disabled={processando}
aria-label="Remover membro"
>
<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>
</button>
</div>
{/each}
</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 membro neste time ainda.</span>
</div>
{/if}
</div>
<div class="divider"></div>
<!-- Adicionar Membros -->
<div>
<h4 class="font-bold text-lg mb-3">Adicionar Membros</h4>
{#if membrosDisponiveis.length > 0}
<div class="space-y-2 max-h-60 overflow-y-auto">
{#each membrosDisponiveis as funcionario}
<div class="flex items-center justify-between p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors">
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-10">
<span class="text-xs">{funcionario.nome.substring(0, 2).toUpperCase()}</span>
</div>
</div>
<div>
<div class="font-semibold">{funcionario.nome}</div>
<div class="text-xs text-base-content/50">{funcionario.matricula || "S/N"}</div>
</div>
</div>
<button
class="btn btn-sm btn-primary"
onclick={() => adicionarMembro(funcionario._id)}
disabled={processando}
>
Adicionar
</button>
</div>
{/each}
</div>
{:else}
<div class="alert">
<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>Todos os funcionários já estão em times.</span>
</div>
{/if}
</div>
<div class="modal-action">
<button class="btn" onclick={fecharModalMembros}>Fechar</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={fecharModalMembros} aria-label="Fechar modal">Fechar</button>
</form>
</dialog>
{/if}
<!-- Modal de Confirmação de Exclusão -->
{#if mostrarConfirmacaoExclusao && timeParaExcluir}
<dialog class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg">Confirmar Desativação</h3>
<p class="py-4">
Tem certeza que deseja desativar o time <strong>{timeParaExcluir.nome}</strong>?
Todos os membros serão removidos.
</p>
<div class="modal-action">
<button class="btn" onclick={() => mostrarConfirmacaoExclusao = false} disabled={processando}>
Cancelar
</button>
<button class="btn btn-error" onclick={excluirTime} disabled={processando}>
{processando ? "Processando..." : "Desativar"}
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={() => mostrarConfirmacaoExclusao = false} aria-label="Fechar modal">Fechar</button>
</form>
</dialog>
{/if}
</main>

View File

@@ -18,9 +18,11 @@ import type * as betterAuth_auth from "../betterAuth/auth.js";
import type * as chat from "../chat.js";
import type * as configuracaoEmail from "../configuracaoEmail.js";
import type * as crons from "../crons.js";
import type * as cursos from "../cursos.js";
import type * as dashboard from "../dashboard.js";
import type * as documentos from "../documentos.js";
import type * as email from "../email.js";
import type * as ferias from "../ferias.js";
import type * as funcionarios from "../funcionarios.js";
import type * as healthCheck from "../healthCheck.js";
import type * as http from "../http.js";
@@ -29,6 +31,7 @@ import type * as logsAcesso from "../logsAcesso.js";
import type * as logsAtividades from "../logsAtividades.js";
import type * as logsLogin from "../logsLogin.js";
import type * as menuPermissoes from "../menuPermissoes.js";
import type * as migrarParaTimes from "../migrarParaTimes.js";
import type * as migrarUsuariosAdmin from "../migrarUsuariosAdmin.js";
import type * as monitoramento from "../monitoramento.js";
import type * as perfisCustomizados from "../perfisCustomizados.js";
@@ -37,6 +40,7 @@ import type * as seed from "../seed.js";
import type * as simbolos from "../simbolos.js";
import type * as solicitacoesAcesso from "../solicitacoesAcesso.js";
import type * as templatesMensagens from "../templatesMensagens.js";
import type * as times from "../times.js";
import type * as todos from "../todos.js";
import type * as usuarios from "../usuarios.js";
import type * as verificarMatriculas from "../verificarMatriculas.js";
@@ -66,9 +70,11 @@ declare const fullApi: ApiFromModules<{
chat: typeof chat;
configuracaoEmail: typeof configuracaoEmail;
crons: typeof crons;
cursos: typeof cursos;
dashboard: typeof dashboard;
documentos: typeof documentos;
email: typeof email;
ferias: typeof ferias;
funcionarios: typeof funcionarios;
healthCheck: typeof healthCheck;
http: typeof http;
@@ -77,6 +83,7 @@ declare const fullApi: ApiFromModules<{
logsAtividades: typeof logsAtividades;
logsLogin: typeof logsLogin;
menuPermissoes: typeof menuPermissoes;
migrarParaTimes: typeof migrarParaTimes;
migrarUsuariosAdmin: typeof migrarUsuariosAdmin;
monitoramento: typeof monitoramento;
perfisCustomizados: typeof perfisCustomizados;
@@ -85,6 +92,7 @@ declare const fullApi: ApiFromModules<{
simbolos: typeof simbolos;
solicitacoesAcesso: typeof solicitacoesAcesso;
templatesMensagens: typeof templatesMensagens;
times: typeof times;
todos: typeof todos;
usuarios: typeof usuarios;
verificarMatriculas: typeof verificarMatriculas;

View File

@@ -17,5 +17,13 @@ crons.interval(
internal.chat.limparIndicadoresDigitacao
);
// Atualizar status de férias dos funcionários diariamente
crons.interval(
"atualizar-status-ferias",
{ hours: 24 },
internal.ferias.atualizarStatusTodosFuncionarios,
{}
);
export default crons;

View File

@@ -0,0 +1,67 @@
import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
export const listarPorFuncionario = query({
args: {
funcionarioId: v.id("funcionarios"),
},
returns: v.array(
v.object({
_id: v.id("cursos"),
_creationTime: v.number(),
funcionarioId: v.id("funcionarios"),
descricao: v.string(),
data: v.string(),
certificadoId: v.optional(v.id("_storage")),
})
),
handler: async (ctx, args) => {
return await ctx.db
.query("cursos")
.withIndex("by_funcionario", (q) =>
q.eq("funcionarioId", args.funcionarioId)
)
.collect();
},
});
export const criar = mutation({
args: {
funcionarioId: v.id("funcionarios"),
descricao: v.string(),
data: v.string(),
certificadoId: v.optional(v.id("_storage")),
},
returns: v.id("cursos"),
handler: async (ctx, args) => {
const cursoId = await ctx.db.insert("cursos", args);
return cursoId;
},
});
export const atualizar = mutation({
args: {
id: v.id("cursos"),
descricao: v.string(),
data: v.string(),
certificadoId: v.optional(v.id("_storage")),
},
returns: v.null(),
handler: async (ctx, args) => {
const { id, ...updates } = args;
await ctx.db.patch(id, updates);
return null;
},
});
export const excluir = mutation({
args: {
id: v.id("cursos"),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.delete(args.id);
return null;
},
});

View File

@@ -0,0 +1,475 @@
import { v } from "convex/values";
import { mutation, query, internalMutation } from "./_generated/server";
import { Id } from "./_generated/dataModel";
// Validador para períodos
const periodoValidator = v.object({
dataInicio: v.string(),
dataFim: v.string(),
diasCorridos: v.number(),
});
// Query: Listar TODAS as solicitações (para RH)
export const listarTodas = query({
args: {},
returns: v.array(v.any()),
handler: async (ctx) => {
const solicitacoes = await ctx.db.query("solicitacoesFerias").collect();
const solicitacoesComDetalhes = await Promise.all(
solicitacoes.map(async (s) => {
const funcionario = await ctx.db.get(s.funcionarioId);
// Buscar time do funcionário
const membroTime = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", s.funcionarioId))
.filter((q) => q.eq(q.field("ativo"), true))
.first();
let time = null;
if (membroTime) {
time = await ctx.db.get(membroTime.timeId);
}
return {
...s,
funcionario,
time,
};
})
);
return solicitacoesComDetalhes.sort((a, b) => b._creationTime - a._creationTime);
},
});
// Query: Listar solicitações do funcionário
export const listarMinhasSolicitacoes = query({
args: { funcionarioId: v.id("funcionarios") },
returns: v.array(v.any()),
handler: async (ctx, args) => {
return await ctx.db
.query("solicitacoesFerias")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.funcionarioId))
.order("desc")
.collect();
},
});
// Query: Listar solicitações dos subordinados (para gestores)
export const listarSolicitacoesSubordinados = query({
args: { gestorId: v.id("usuarios") },
returns: v.array(v.any()),
handler: async (ctx, args) => {
// Buscar times onde o usuário é gestor
const timesGestor = await ctx.db
.query("times")
.withIndex("by_gestor", (q) => q.eq("gestorId", args.gestorId))
.filter((q) => q.eq(q.field("ativo"), true))
.collect();
const solicitacoes: Array<any> = [];
for (const time of timesGestor) {
// Buscar membros do time
const membros = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", time._id).eq("ativo", true))
.collect();
// Buscar solicitações de cada membro
for (const membro of membros) {
const solic = await ctx.db
.query("solicitacoesFerias")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", membro.funcionarioId))
.collect();
// Adicionar info do funcionário
for (const s of solic) {
const funcionario = await ctx.db.get(s.funcionarioId);
solicitacoes.push({
...s,
funcionario,
time,
});
}
}
}
return solicitacoes.sort((a, b) => b._creationTime - a._creationTime);
},
});
// Query: Obter detalhes completos de uma solicitação
export const obterDetalhes = query({
args: { solicitacaoId: v.id("solicitacoesFerias") },
returns: v.union(v.any(), v.null()),
handler: async (ctx, args) => {
const solicitacao = await ctx.db.get(args.solicitacaoId);
if (!solicitacao) return null;
const funcionario = await ctx.db.get(solicitacao.funcionarioId);
let gestor = null;
if (solicitacao.gestorId) {
gestor = await ctx.db.get(solicitacao.gestorId);
}
return {
...solicitacao,
funcionario,
gestor,
};
},
});
// Mutation: Criar solicitação de férias
export const criarSolicitacao = mutation({
args: {
funcionarioId: v.id("funcionarios"),
anoReferencia: v.number(),
periodos: v.array(periodoValidator),
observacao: v.optional(v.string()),
},
returns: v.id("solicitacoesFerias"),
handler: async (ctx, args) => {
if (args.periodos.length === 0) {
throw new Error("É necessário adicionar pelo menos 1 período");
}
if (args.periodos.length > 3) {
throw new Error("Máximo de 3 períodos permitidos");
}
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario) throw new Error("Funcionário não encontrado");
// Buscar usuário que está criando (pode não ser o próprio funcionário)
const usuario = await ctx.db
.query("usuarios")
.withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", args.funcionarioId))
.first();
const solicitacaoId = await ctx.db.insert("solicitacoesFerias", {
funcionarioId: args.funcionarioId,
anoReferencia: args.anoReferencia,
status: "aguardando_aprovacao",
periodos: args.periodos,
observacao: args.observacao,
historicoAlteracoes: [{
data: Date.now(),
usuarioId: usuario?._id || funcionario.gestorId!,
acao: "Solicitação criada",
}],
});
// Notificar gestor
if (funcionario.gestorId) {
await ctx.db.insert("notificacoesFerias", {
destinatarioId: funcionario.gestorId,
solicitacaoFeriasId: solicitacaoId,
tipo: "nova_solicitacao",
lida: false,
mensagem: `${funcionario.nome} solicitou férias`,
});
}
return solicitacaoId;
},
});
// Mutation: Aprovar férias
export const aprovar = mutation({
args: {
solicitacaoId: v.id("solicitacoesFerias"),
gestorId: v.id("usuarios"),
},
returns: v.null(),
handler: async (ctx, args) => {
const solicitacao = await ctx.db.get(args.solicitacaoId);
if (!solicitacao) throw new Error("Solicitação não encontrada");
if (solicitacao.status !== "aguardando_aprovacao") {
throw new Error("Esta solicitação já foi processada");
}
const funcionario = await ctx.db.get(solicitacao.funcionarioId);
await ctx.db.patch(args.solicitacaoId, {
status: "aprovado",
gestorId: args.gestorId,
dataAprovacao: Date.now(),
historicoAlteracoes: [
...(solicitacao.historicoAlteracoes || []),
{
data: Date.now(),
usuarioId: args.gestorId,
acao: "Aprovado",
},
],
});
// Notificar funcionário
if (funcionario) {
const usuario = await ctx.db
.query("usuarios")
.withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id))
.first();
if (usuario) {
await ctx.db.insert("notificacoesFerias", {
destinatarioId: usuario._id,
solicitacaoFeriasId: args.solicitacaoId,
tipo: "aprovado",
lida: false,
mensagem: "Suas férias foram aprovadas!",
});
}
}
return null;
},
});
// Mutation: Reprovar férias
export const reprovar = mutation({
args: {
solicitacaoId: v.id("solicitacoesFerias"),
gestorId: v.id("usuarios"),
motivoReprovacao: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const solicitacao = await ctx.db.get(args.solicitacaoId);
if (!solicitacao) throw new Error("Solicitação não encontrada");
if (solicitacao.status !== "aguardando_aprovacao") {
throw new Error("Esta solicitação já foi processada");
}
const funcionario = await ctx.db.get(solicitacao.funcionarioId);
await ctx.db.patch(args.solicitacaoId, {
status: "reprovado",
gestorId: args.gestorId,
dataReprovacao: Date.now(),
motivoReprovacao: args.motivoReprovacao,
historicoAlteracoes: [
...(solicitacao.historicoAlteracoes || []),
{
data: Date.now(),
usuarioId: args.gestorId,
acao: `Reprovado: ${args.motivoReprovacao}`,
},
],
});
// Notificar funcionário
if (funcionario) {
const usuario = await ctx.db
.query("usuarios")
.withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id))
.first();
if (usuario) {
await ctx.db.insert("notificacoesFerias", {
destinatarioId: usuario._id,
solicitacaoFeriasId: args.solicitacaoId,
tipo: "reprovado",
lida: false,
mensagem: `Suas férias foram reprovadas: ${args.motivoReprovacao}`,
});
}
}
return null;
},
});
// Mutation: Ajustar data e aprovar
export const ajustarEAprovar = mutation({
args: {
solicitacaoId: v.id("solicitacoesFerias"),
gestorId: v.id("usuarios"),
novosPeriodos: v.array(periodoValidator),
},
returns: v.null(),
handler: async (ctx, args) => {
const solicitacao = await ctx.db.get(args.solicitacaoId);
if (!solicitacao) throw new Error("Solicitação não encontrada");
if (solicitacao.status !== "aguardando_aprovacao") {
throw new Error("Esta solicitação já foi processada");
}
if (args.novosPeriodos.length === 0) {
throw new Error("É necessário adicionar pelo menos 1 período");
}
if (args.novosPeriodos.length > 3) {
throw new Error("Máximo de 3 períodos permitidos");
}
const funcionario = await ctx.db.get(solicitacao.funcionarioId);
await ctx.db.patch(args.solicitacaoId, {
status: "data_ajustada_aprovada",
periodos: args.novosPeriodos,
gestorId: args.gestorId,
dataAprovacao: Date.now(),
historicoAlteracoes: [
...(solicitacao.historicoAlteracoes || []),
{
data: Date.now(),
usuarioId: args.gestorId,
acao: "Data ajustada e aprovada",
periodosAnteriores: solicitacao.periodos,
},
],
});
// Notificar funcionário
if (funcionario) {
const usuario = await ctx.db
.query("usuarios")
.withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id))
.first();
if (usuario) {
await ctx.db.insert("notificacoesFerias", {
destinatarioId: usuario._id,
solicitacaoFeriasId: args.solicitacaoId,
tipo: "data_ajustada",
lida: false,
mensagem: "Suas férias foram aprovadas com ajuste de datas",
});
}
}
return null;
},
});
// Query: Verificar status de férias automático
export const verificarStatusFerias = query({
args: { funcionarioId: v.id("funcionarios") },
returns: v.union(v.literal("ativo"), v.literal("em_ferias")),
handler: async (ctx, args) => {
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
const solicitacoesAprovadas = await ctx.db
.query("solicitacoesFerias")
.withIndex("by_funcionario_and_status", (q) =>
q.eq("funcionarioId", args.funcionarioId)
.eq("status", "aprovado")
)
.collect();
const solicitacoesAjustadas = await ctx.db
.query("solicitacoesFerias")
.withIndex("by_funcionario_and_status", (q) =>
q.eq("funcionarioId", args.funcionarioId)
.eq("status", "data_ajustada_aprovada")
)
.collect();
const todasSolicitacoes = [...solicitacoesAprovadas, ...solicitacoesAjustadas];
for (const solicitacao of todasSolicitacoes) {
for (const periodo of solicitacao.periodos) {
const inicio = new Date(periodo.dataInicio);
const fim = new Date(periodo.dataFim);
inicio.setHours(0, 0, 0, 0);
fim.setHours(23, 59, 59, 999);
if (hoje >= inicio && hoje <= fim) {
return "em_ferias";
}
}
}
return "ativo";
},
});
// Query: Obter notificações não lidas
export const obterNotificacoesNaoLidas = query({
args: { usuarioId: v.id("usuarios") },
returns: v.array(v.any()),
handler: async (ctx, args) => {
return await ctx.db
.query("notificacoesFerias")
.withIndex("by_destinatario_and_lida", (q) =>
q.eq("destinatarioId", args.usuarioId).eq("lida", false)
)
.collect();
},
});
// Mutation: Marcar notificação como lida
export const marcarComoLida = mutation({
args: { notificacaoId: v.id("notificacoesFerias") },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.notificacaoId, { lida: true });
return null;
},
});
// Internal Mutation: Atualizar status de todos os funcionários
export const atualizarStatusTodosFuncionarios = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const funcionarios = await ctx.db.query("funcionarios").collect();
for (const func of funcionarios) {
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
const solicitacoesAprovadas = await ctx.db
.query("solicitacoesFerias")
.withIndex("by_funcionario_and_status", (q) =>
q.eq("funcionarioId", func._id)
.eq("status", "aprovado")
)
.collect();
const solicitacoesAjustadas = await ctx.db
.query("solicitacoesFerias")
.withIndex("by_funcionario_and_status", (q) =>
q.eq("funcionarioId", func._id)
.eq("status", "data_ajustada_aprovada")
)
.collect();
const todasSolicitacoes = [...solicitacoesAprovadas, ...solicitacoesAjustadas];
let emFerias = false;
for (const solicitacao of todasSolicitacoes) {
for (const periodo of solicitacao.periodos) {
const inicio = new Date(periodo.dataInicio);
const fim = new Date(periodo.dataFim);
inicio.setHours(0, 0, 0, 0);
fim.setHours(23, 59, 59, 999);
if (hoje >= inicio && hoje <= fim) {
emFerias = true;
break;
}
}
if (emFerias) break;
}
const novoStatus = emFerias ? "em_ferias" : "ativo";
if (func.statusFerias !== novoStatus) {
await ctx.db.patch(func._id, { statusFerias: novoStatus });
}
}
return null;
},
});

View File

@@ -48,7 +48,7 @@ export const create = mutation({
args: {
// Campos obrigatórios
nome: v.string(),
matricula: v.string(),
matricula: v.optional(v.string()),
simboloId: v.id("simbolos"),
nascimento: v.string(),
rg: v.string(),
@@ -149,13 +149,15 @@ export const create = mutation({
throw new Error("CPF já cadastrado");
}
// Unicidade: Matrícula
const matriculaExists = await ctx.db
.query("funcionarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.unique();
if (matriculaExists) {
throw new Error("Matrícula já cadastrada");
// Unicidade: Matrícula (apenas se fornecida)
if (args.matricula) {
const matriculaExists = await ctx.db
.query("funcionarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.unique();
if (matriculaExists) {
throw new Error("Já existe um funcionário com esta matrícula. Por favor, use outra ou deixe em branco.");
}
}
const novoFuncionarioId = await ctx.db.insert("funcionarios", args as any);
@@ -168,7 +170,7 @@ export const update = mutation({
id: v.id("funcionarios"),
// Campos obrigatórios
nome: v.string(),
matricula: v.string(),
matricula: v.optional(v.string()),
simboloId: v.id("simbolos"),
nascimento: v.string(),
rg: v.string(),
@@ -269,13 +271,15 @@ export const update = mutation({
throw new Error("CPF já cadastrado");
}
// Unicidade: Matrícula (excluindo o próprio registro)
const matriculaExists = await ctx.db
.query("funcionarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.unique();
if (matriculaExists && matriculaExists._id !== args.id) {
throw new Error("Matrícula já cadastrada");
// Unicidade: Matrícula (apenas se fornecida, excluindo o próprio registro)
if (args.matricula) {
const matriculaExists = await ctx.db
.query("funcionarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.unique();
if (matriculaExists && matriculaExists._id !== args.id) {
throw new Error("Já existe um funcionário com esta matrícula. Por favor, use outra ou deixe em branco.");
}
}
const { id, ...updateData } = args;
@@ -306,13 +310,52 @@ export const getFichaCompleta = query({
// Buscar informações do símbolo
const simbolo = await ctx.db.get(funcionario.simboloId);
// Buscar cursos do funcionário
const cursos = await ctx.db
.query("cursos")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.id))
.collect();
// Buscar URLs dos certificados
const cursosComUrls = await Promise.all(
cursos.map(async (curso) => {
let certificadoUrl = null;
if (curso.certificadoId) {
certificadoUrl = await ctx.storage.getUrl(curso.certificadoId);
}
return {
...curso,
certificadoUrl,
};
})
);
return {
...funcionario,
simbolo: simbolo ? {
nome: simbolo.nome,
descricao: simbolo.descricao,
tipo: simbolo.tipo,
vencValor: simbolo.vencValor,
repValor: simbolo.repValor,
valor: simbolo.valor,
} : null,
cursos: cursosComUrls,
};
},
});
// Mutation: Configurar gestor (apenas para TI_MASTER)
export const configurarGestor = mutation({
args: {
funcionarioId: v.id("funcionarios"),
gestorId: v.optional(v.id("usuarios")),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.funcionarioId, {
gestorId: args.gestorId,
});
return null;
},
});

View File

@@ -0,0 +1,171 @@
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";
/**
* Migração: Converte estrutura antiga de gestores individuais para times
*
* Esta função cria automaticamente times baseados nos gestores existentes
* e adiciona os funcionários subordinados aos respectivos times.
*
* Execute uma vez via dashboard do Convex:
* Settings > Functions > Internal > migrarParaTimes > executar
*/
export const executar = internalMutation({
args: {},
returns: v.object({
timesCreated: v.number(),
funcionariosAtribuidos: v.number(),
erros: v.array(v.string()),
}),
handler: async (ctx) => {
const erros: string[] = [];
let timesCreated = 0;
let funcionariosAtribuidos = 0;
try {
// 1. Buscar todos os funcionários que têm gestor definido
const funcionariosComGestor = await ctx.db
.query("funcionarios")
.filter((q) => q.neq(q.field("gestorId"), undefined))
.collect();
if (funcionariosComGestor.length === 0) {
return {
timesCreated: 0,
funcionariosAtribuidos: 0,
erros: ["Nenhum funcionário com gestor configurado encontrado"],
};
}
// 2. Agrupar funcionários por gestor
const gestoresMap = new Map<string, any[]>();
for (const funcionario of funcionariosComGestor) {
if (!funcionario.gestorId) continue;
const gestorId = funcionario.gestorId;
if (!gestoresMap.has(gestorId)) {
gestoresMap.set(gestorId, []);
}
gestoresMap.get(gestorId)!.push(funcionario);
}
// 3. Para cada gestor, criar um time
for (const [gestorId, subordinados] of gestoresMap.entries()) {
try {
const gestor = await ctx.db.get(gestorId as any);
if (!gestor) {
erros.push(`Gestor ${gestorId} não encontrado`);
continue;
}
// Verificar se já existe time para este gestor
const timeExistente = await ctx.db
.query("times")
.withIndex("by_gestor", (q) => q.eq("gestorId", gestorId as any))
.filter((q) => q.eq(q.field("ativo"), true))
.first();
let timeId;
if (timeExistente) {
timeId = timeExistente._id;
} else {
// Criar novo time
timeId = await ctx.db.insert("times", {
nome: `Equipe ${gestor.nome}`,
descricao: `Time gerenciado por ${gestor.nome} (migração automática)`,
gestorId: gestorId as any,
ativo: true,
cor: "#3B82F6",
});
timesCreated++;
}
// Adicionar membros ao time
for (const funcionario of subordinados) {
try {
// Verificar se já está em algum time
const membroExistente = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", funcionario._id))
.filter((q) => q.eq(q.field("ativo"), true))
.first();
if (!membroExistente) {
await ctx.db.insert("timesMembros", {
timeId: timeId,
funcionarioId: funcionario._id,
dataEntrada: Date.now(),
ativo: true,
});
funcionariosAtribuidos++;
}
} catch (e: any) {
erros.push(`Erro ao adicionar ${funcionario.nome} ao time: ${e.message}`);
}
}
} catch (e: any) {
erros.push(`Erro ao processar gestor ${gestorId}: ${e.message}`);
}
}
return {
timesCreated,
funcionariosAtribuidos,
erros,
};
} catch (e: any) {
erros.push(`Erro geral na migração: ${e.message}`);
return {
timesCreated,
funcionariosAtribuidos,
erros,
};
}
},
});
/**
* Função auxiliar para limpar times inativos antigos
*/
export const limparTimesInativos = internalMutation({
args: {
diasInativos: v.optional(v.number()),
},
returns: v.number(),
handler: async (ctx, args) => {
const diasLimite = args.diasInativos || 30;
const dataLimite = Date.now() - (diasLimite * 24 * 60 * 60 * 1000);
const timesInativos = await ctx.db
.query("times")
.filter((q) => q.eq(q.field("ativo"), false))
.collect();
let removidos = 0;
for (const time of timesInativos) {
if (time._creationTime < dataLimite) {
// Remover membros inativos do time
const membrosInativos = await ctx.db
.query("timesMembros")
.withIndex("by_time", (q) => q.eq("timeId", time._id))
.filter((q) => q.eq(q.field("ativo"), false))
.collect();
for (const membro of membrosInativos) {
await ctx.db.delete(membro._id);
}
// Remover o time
await ctx.db.delete(time._id);
removidos++;
}
}
return removidos;
},
});

View File

@@ -26,11 +26,16 @@ export default defineSchema({
uf: v.string(),
telefone: v.string(),
email: v.string(),
matricula: v.string(),
matricula: v.optional(v.string()),
admissaoData: v.optional(v.string()),
desligamentoData: v.optional(v.string()),
simboloId: v.id("simbolos"),
simboloTipo: simboloTipo,
gestorId: v.optional(v.id("usuarios")),
statusFerias: v.optional(v.union(
v.literal("ativo"),
v.literal("em_ferias")
)),
// Dados Pessoais Adicionais (opcionais)
nomePai: v.optional(v.string()),
@@ -135,7 +140,8 @@ export default defineSchema({
.index("by_simboloId", ["simboloId"])
.index("by_simboloTipo", ["simboloTipo"])
.index("by_cpf", ["cpf"])
.index("by_rg", ["rg"]),
.index("by_rg", ["rg"])
.index("by_gestor", ["gestorId"]),
atestados: defineTable({
funcionarioId: v.id("funcionarios"),
@@ -145,11 +151,87 @@ export default defineSchema({
descricao: v.string(),
}),
ferias: defineTable({
solicitacoesFerias: defineTable({
funcionarioId: v.id("funcionarios"),
dataInicio: v.string(),
dataFim: v.string(),
}),
anoReferencia: v.number(),
status: v.union(
v.literal("aguardando_aprovacao"),
v.literal("aprovado"),
v.literal("reprovado"),
v.literal("data_ajustada_aprovada")
),
periodos: v.array(
v.object({
dataInicio: v.string(),
dataFim: v.string(),
diasCorridos: v.number(),
})
),
observacao: v.optional(v.string()),
motivoReprovacao: v.optional(v.string()),
gestorId: v.optional(v.id("usuarios")),
dataAprovacao: v.optional(v.number()),
dataReprovacao: v.optional(v.number()),
historicoAlteracoes: v.optional(
v.array(
v.object({
data: v.number(),
usuarioId: v.id("usuarios"),
acao: v.string(),
periodosAnteriores: v.optional(v.array(v.object({
dataInicio: v.string(),
dataFim: v.string(),
diasCorridos: v.number(),
}))),
})
)
),
})
.index("by_funcionario", ["funcionarioId"])
.index("by_status", ["status"])
.index("by_funcionario_and_status", ["funcionarioId", "status"])
.index("by_ano", ["anoReferencia"]),
notificacoesFerias: defineTable({
destinatarioId: v.id("usuarios"),
solicitacaoFeriasId: v.id("solicitacoesFerias"),
tipo: v.union(
v.literal("nova_solicitacao"),
v.literal("aprovado"),
v.literal("reprovado"),
v.literal("data_ajustada")
),
lida: v.boolean(),
mensagem: v.string(),
})
.index("by_destinatario", ["destinatarioId"])
.index("by_destinatario_and_lida", ["destinatarioId", "lida"]),
times: defineTable({
nome: v.string(),
descricao: v.optional(v.string()),
gestorId: v.id("usuarios"),
ativo: v.boolean(),
cor: v.optional(v.string()), // Cor para identificação visual
}).index("by_gestor", ["gestorId"]),
timesMembros: defineTable({
timeId: v.id("times"),
funcionarioId: v.id("funcionarios"),
dataEntrada: v.number(),
dataSaida: v.optional(v.number()),
ativo: v.boolean(),
})
.index("by_time", ["timeId"])
.index("by_funcionario", ["funcionarioId"])
.index("by_time_and_ativo", ["timeId", "ativo"]),
cursos: defineTable({
funcionarioId: v.id("funcionarios"),
descricao: v.string(),
data: v.string(),
certificadoId: v.optional(v.id("_storage")),
}).index("by_funcionario", ["funcionarioId"]),
simbolos: defineTable({
nome: v.string(),

View File

@@ -0,0 +1,270 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { Id } from "./_generated/dataModel";
// Query: Listar todos os times
export const listar = query({
args: {},
returns: v.array(v.any()),
handler: async (ctx) => {
const times = await ctx.db.query("times").collect();
// Buscar gestor e contar membros de cada time
const timesComDetalhes = await Promise.all(
times.map(async (time) => {
const gestor = await ctx.db.get(time.gestorId);
const membrosAtivos = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", time._id).eq("ativo", true))
.collect();
return {
...time,
gestor,
totalMembros: membrosAtivos.length,
};
})
);
return timesComDetalhes;
},
});
// Query: Obter time por ID com membros
export const obterPorId = query({
args: { id: v.id("times") },
returns: v.union(v.any(), v.null()),
handler: async (ctx, args) => {
const time = await ctx.db.get(args.id);
if (!time) return null;
const gestor = await ctx.db.get(time.gestorId);
const membrosRelacoes = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", args.id).eq("ativo", true))
.collect();
// Buscar dados completos dos membros
const membros = await Promise.all(
membrosRelacoes.map(async (rel) => {
const funcionario = await ctx.db.get(rel.funcionarioId);
return {
...rel,
funcionario,
};
})
);
return {
...time,
gestor,
membros,
};
},
});
// Query: Obter time do funcionário
export const obterTimeFuncionario = query({
args: { funcionarioId: v.id("funcionarios") },
returns: v.union(v.any(), v.null()),
handler: async (ctx, args) => {
const relacao = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.funcionarioId))
.filter((q) => q.eq(q.field("ativo"), true))
.first();
if (!relacao) return null;
const time = await ctx.db.get(relacao.timeId);
if (!time) return null;
const gestor = await ctx.db.get(time.gestorId);
return {
...time,
gestor,
};
},
});
// Query: Obter times do gestor
export const listarPorGestor = query({
args: { gestorId: v.id("usuarios") },
returns: v.array(v.any()),
handler: async (ctx, args) => {
const times = await ctx.db
.query("times")
.withIndex("by_gestor", (q) => q.eq("gestorId", args.gestorId))
.filter((q) => q.eq(q.field("ativo"), true))
.collect();
const timesComMembros = await Promise.all(
times.map(async (time) => {
const membrosRelacoes = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", time._id).eq("ativo", true))
.collect();
const membros = await Promise.all(
membrosRelacoes.map(async (rel) => {
const funcionario = await ctx.db.get(rel.funcionarioId);
return {
...rel,
funcionario,
};
})
);
return {
...time,
membros,
};
})
);
return timesComMembros;
},
});
// Mutation: Criar time
export const criar = mutation({
args: {
nome: v.string(),
descricao: v.optional(v.string()),
gestorId: v.id("usuarios"),
cor: v.optional(v.string()),
},
returns: v.id("times"),
handler: async (ctx, args) => {
const timeId = await ctx.db.insert("times", {
nome: args.nome,
descricao: args.descricao,
gestorId: args.gestorId,
ativo: true,
cor: args.cor || "#3B82F6",
});
return timeId;
},
});
// Mutation: Atualizar time
export const atualizar = mutation({
args: {
id: v.id("times"),
nome: v.string(),
descricao: v.optional(v.string()),
gestorId: v.id("usuarios"),
cor: v.optional(v.string()),
},
returns: v.null(),
handler: async (ctx, args) => {
const { id, ...dados } = args;
await ctx.db.patch(id, dados);
return null;
},
});
// Mutation: Desativar time
export const desativar = mutation({
args: { id: v.id("times") },
returns: v.null(),
handler: async (ctx, args) => {
// Desativar o time
await ctx.db.patch(args.id, { ativo: false });
// Desativar todos os membros
const membros = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) => q.eq("timeId", args.id).eq("ativo", true))
.collect();
for (const membro of membros) {
await ctx.db.patch(membro._id, {
ativo: false,
dataSaida: Date.now(),
});
}
return null;
},
});
// Mutation: Adicionar membro ao time
export const adicionarMembro = mutation({
args: {
timeId: v.id("times"),
funcionarioId: v.id("funcionarios"),
},
returns: v.id("timesMembros"),
handler: async (ctx, args) => {
// Verificar se já não está em outro time ativo
const membroExistente = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.funcionarioId))
.filter((q) => q.eq(q.field("ativo"), true))
.first();
if (membroExistente) {
throw new Error("Funcionário já está em um time ativo");
}
const membroId = await ctx.db.insert("timesMembros", {
timeId: args.timeId,
funcionarioId: args.funcionarioId,
dataEntrada: Date.now(),
ativo: true,
});
return membroId;
},
});
// Mutation: Remover membro do time
export const removerMembro = mutation({
args: { membroId: v.id("timesMembros") },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.membroId, {
ativo: false,
dataSaida: Date.now(),
});
return null;
},
});
// Mutation: Transferir membro para outro time
export const transferirMembro = mutation({
args: {
funcionarioId: v.id("funcionarios"),
novoTimeId: v.id("times"),
},
returns: v.null(),
handler: async (ctx, args) => {
// Desativar do time atual
const relacaoAtual = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.funcionarioId))
.filter((q) => q.eq(q.field("ativo"), true))
.first();
if (relacaoAtual) {
await ctx.db.patch(relacaoAtual._id, {
ativo: false,
dataSaida: Date.now(),
});
}
// Adicionar ao novo time
await ctx.db.insert("timesMembros", {
timeId: args.novoTimeId,
funcionarioId: args.funcionarioId,
dataEntrada: Date.now(),
ativo: true,
});
return null;
},
});