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:
378
apps/web/src/lib/components/AprovarFerias.svelte
Normal file
378
apps/web/src/lib/components/AprovarFerias.svelte
Normal 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>
|
||||
|
||||
304
apps/web/src/lib/components/SolicitarFerias.svelte
Normal file
304
apps/web/src/lib/components/SolicitarFerias.svelte
Normal 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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -6,6 +6,7 @@ interface Usuario {
|
||||
matricula: string;
|
||||
nome: string;
|
||||
email: string;
|
||||
funcionarioId?: string;
|
||||
role: {
|
||||
_id: string;
|
||||
nome: string;
|
||||
|
||||
@@ -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 uploadingFoto = $state(false);
|
||||
let salvando = $state(false);
|
||||
let mensagemSucesso = $state("");
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
// 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" },
|
||||
|
||||
// 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" },
|
||||
];
|
||||
let abaAtiva = $state<"meu-perfil" | "minhas-ferias" | "aprovar-ferias">("meu-perfil");
|
||||
let mostrarFormSolicitar = $state(false);
|
||||
let solicitacaoSelecionada = $state<any>(null);
|
||||
|
||||
function getAvatarUrl(avatarId: string): string {
|
||||
// Usar gerador local ao invés da API externa
|
||||
return generateAvatarUrl(avatarId);
|
||||
// Queries
|
||||
const funcionarioQuery = $derived(
|
||||
authStore.usuario?.funcionarioId
|
||||
? useQuery(api.funcionarios.getById, { id: authStore.usuario.funcionarioId as any })
|
||||
: { data: null }
|
||||
);
|
||||
|
||||
const minhasSolicitacoesQuery = $derived(
|
||||
funcionarioQuery.data
|
||||
? useQuery(api.ferias.listarMinhasSolicitacoes, { funcionarioId: funcionarioQuery.data._id })
|
||||
: { data: [] }
|
||||
);
|
||||
|
||||
const solicitacoesSubordinadosQuery = $derived(
|
||||
authStore.usuario?._id
|
||||
? useQuery(api.ferias.listarSolicitacoesSubordinados, { gestorId: authStore.usuario._id as any })
|
||||
: { data: [] }
|
||||
);
|
||||
|
||||
const meuTimeQuery = $derived(
|
||||
funcionarioQuery.data
|
||||
? useQuery(api.times.obterTimeFuncionario, { funcionarioId: funcionarioQuery.data._id })
|
||||
: { data: null }
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
{#if perfil}
|
||||
<div class="grid gap-6">
|
||||
<!-- Card 1: Foto de Perfil -->
|
||||
<!-- 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -92,6 +92,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);
|
||||
@@ -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" };
|
||||
@@ -193,6 +228,51 @@
|
||||
uf = data.uf || "";
|
||||
} 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 {
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
505
apps/web/src/routes/(dashboard)/ti/times/+page.svelte
Normal file
505
apps/web/src/routes/(dashboard)/ti/times/+page.svelte
Normal 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>
|
||||
|
||||
8
packages/backend/convex/_generated/api.d.ts
vendored
8
packages/backend/convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
67
packages/backend/convex/cursos.ts
Normal file
67
packages/backend/convex/cursos.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
|
||||
475
packages/backend/convex/ferias.ts
Normal file
475
packages/backend/convex/ferias.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
171
packages/backend/convex/migrarParaTimes.ts
Normal file
171
packages/backend/convex/migrarParaTimes.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
270
packages/backend/convex/times.ts
Normal file
270
packages/backend/convex/times.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user