Feat cadastro funcinarios #2

Merged
deyvisonwanderley merged 8 commits from feat-cadastro-funcinarios into master 2025-10-28 15:01:44 +00:00
12 changed files with 1504 additions and 62 deletions
Showing only changes of commit f0d3625045 - Show all commits

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" data-theme="aqua">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />

View File

@@ -5,7 +5,7 @@
<div class="w-full">
<main
id="container-central"
class="container mx-auto p-4 lg:p-6 max-w-7xl"
class="w-full max-w-none px-3 lg:px-4 pt-8 lg:pt-10 pb-2"
>
{@render children()}
</main>

View File

@@ -0,0 +1,172 @@
<script lang="ts">
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { goto } from "$app/navigation";
import type { SimboloTipo } from "@sgse-app/backend/convex/schema";
const client = useConvexClient();
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 filtroNome = "";
let filtroCPF = "";
let filtroMatricula = "";
let filtroTipo: SimboloTipo | "" = "";
function applyFilters() {
const nome = filtroNome.toLowerCase();
const cpf = filtroCPF.replace(/\D/g, "");
const mat = filtroMatricula.toLowerCase();
filtered = list.filter((f) => {
const okNome = !nome || (f.nome || "").toLowerCase().includes(nome);
const okCPF = !cpf || (f.cpf || "").includes(cpf);
const okMat = !mat || (f.matricula || "").toLowerCase().includes(mat);
const okTipo = !filtroTipo || f.simboloTipo === filtroTipo;
return okNome && okCPF && okMat && okTipo;
});
}
async function load() {
list = await client.query(api.funcionarios.getAll, {} as any);
applyFilters();
}
function editSelected() {
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 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"); }
load();
function toggleMenu(id: string) {
openMenuId = openMenuId === id ? null : id;
}
$: needsScroll = filtered.length > 8;
</script>
<div class="space-y-6 pb-32">
<div class="flex items-center justify-between">
<h2 class="text-3xl font-bold text-brand-dark">Funcionários</h2>
<div class="space-x-2 flex items-center">
<button class="btn btn-primary" onclick={navCadastro}>Novo Funcionário</button>
</div>
</div>
<div class="grid md:grid-cols-4 gap-3 items-end">
<div class="form-control">
<label class="label" for="func_nome"><span class="label-text">Nome</span></label>
<input id="func_nome" class="input input-bordered" bind:value={filtroNome} oninput={applyFilters} />
</div>
<div class="form-control">
<label class="label" for="func_cpf"><span class="label-text">CPF</span></label>
<input id="func_cpf" class="input input-bordered" bind:value={filtroCPF} oninput={applyFilters} />
</div>
<div class="form-control">
<label class="label" for="func_matricula"><span class="label-text">Matrícula</span></label>
<input id="func_matricula" class="input input-bordered" bind:value={filtroMatricula} oninput={applyFilters} />
</div>
<div class="form-control">
<label class="label" for="func_tipo"><span class="label-text">Símbolo Tipo</span></label>
<select id="func_tipo" class="select select-bordered" bind:value={filtroTipo} oninput={applyFilters}>
<option value="">Todos</option>
<option value="cargo_comissionado">Cargo comissionado</option>
<option value="funcao_gratificada">Função gratificada</option>
</select>
</div>
</div>
<div class="overflow-x-auto bg-base-100 rounded-lg shadow-sm mb-2" class:overflow-y-auto={needsScroll} style={needsScroll ? "max-height: calc(100vh - 180px);" : "overflow-y: visible;"}>
<table class="table table-zebra">
<thead>
<tr>
<th>Nome</th>
<th>CPF</th>
<th>Matrícula</th>
<th>Tipo</th>
<th>Cidade</th>
<th>UF</th>
<th class="text-right">Ações</th>
</tr>
</thead>
<tbody>
{#each filtered as f}
<tr class="hover">
<td class="font-medium">{f.nome}</td>
<td>{f.cpf}</td>
<td>{f.matricula}</td>
<td>{f.simboloTipo}</td>
<td>{f.cidade}</td>
<td>{f.uf}</td>
<td class="text-right">
<div class="dropdown dropdown-end" class:dropdown-open={openMenuId === f._id}>
<button type="button" aria-label="Abrir menu" class="btn btn-ghost btn-sm" onclick={() => toggleMenu(f._id)}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z"/></svg>
</button>
<ul class="dropdown-content menu bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg border border-base-300">
<li><a href={`/recursos-humanos/funcionarios/${f._id}/editar`}>Editar</a></li>
<li><button class="text-error" onclick={() => openDeleteModal(f._id, f.nome)}>Excluir</button></li>
</ul>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<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>
</div>

View File

@@ -15,7 +15,39 @@
const onlyDigits = (s: string) => (s || "").replace(/\D/g, "");
const maskCPF = (v: string) => onlyDigits(v).slice(0, 11).replace(/(\d{3})(\d)/, "$1.$2").replace(/(\d{3})(\d)/, "$1.$2").replace(/(\d{3})(\d{1,2})$/, "$1-$2");
const maskRG = (v: string) => onlyDigits(v).slice(0, 9).replace(/(\d{2})(\d)/, "$1.$2").replace(/(\d{3})(\d)/, "$1.$2").replace(/(\d{3})(\d{1})$/, "$1-$2");
const rgFormatByUF: Record<string, [number, number, number, number]> = {
RJ: [2, 3, 2, 1], SP: [2, 3, 3, 1], MG: [2, 3, 3, 1], ES: [2, 3, 3, 1],
PR: [2, 3, 3, 1], SC: [2, 3, 3, 1], RS: [2, 3, 3, 1], BA: [2, 3, 3, 1],
PE: [2, 3, 3, 1], CE: [2, 3, 3, 1], PA: [2, 3, 3, 1], AM: [2, 3, 3, 1],
AC: [2, 3, 3, 1], AP: [2, 3, 3, 1], AL: [2, 3, 3, 1], RN: [2, 3, 3, 1],
PB: [2, 3, 3, 1], MA: [2, 3, 3, 1], PI: [2, 3, 3, 1], DF: [2, 3, 3, 1],
GO: [2, 3, 3, 1], MT: [2, 3, 3, 1], MS: [2, 3, 3, 1], RO: [2, 3, 3, 1],
RR: [2, 3, 3, 1], TO: [2, 3, 3, 1],
};
function maskRGByUF(uf: string, v: string) {
const raw = (v || "").toUpperCase().replace(/[^0-9X]/g, "");
const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1];
const baseMax = a + b + c;
const baseDigits = raw.replace(/X/g, "").slice(0, baseMax);
const verifier = raw.slice(baseDigits.length, baseDigits.length + dv).slice(0, 1);
const g1 = baseDigits.slice(0, a);
const g2 = baseDigits.slice(a, a + b);
const g3 = baseDigits.slice(a + b, a + b + c);
let out = g1;
if (g2) out += `.${g2}`;
if (g3) out += `.${g3}`;
if (verifier) out += `-${verifier}`;
return out;
}
function padRGLeftByUF(uf: string, v: string) {
const raw = (v || "").toUpperCase().replace(/[^0-9X]/g, "");
const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1];
const baseMax = a + b + c;
let base = raw.replace(/X/g, "");
const verifier = raw.slice(base.length, base.length + dv).slice(0, 1);
if (base.length < baseMax) base = base.padStart(baseMax, "0");
return maskRGByUF(uf, base + (verifier || ""));
}
const maskCEP = (v: string) => onlyDigits(v).slice(0, 8).replace(/(\d{5})(\d{1,3})$/, "$1-$2");
const maskPhone = (v: string) => {
const d = onlyDigits(v).slice(0, 11);
@@ -45,7 +77,7 @@
nome: z.string().min(3),
matricula: z.string().min(1),
cpf: z.string().min(1).refine(isValidCPF, "CPF inválido"),
rg: z.string().min(1).refine((v) => /^\d{2}\.\d{3}\.\d{3}-\d$/.test(maskRG(v)), "RG inválido"),
rg: z.string().min(1).refine((v) => /^\d+$/.test(v), "RG inválido"),
nascimento: z.string().refine(isValidDateBR, "Data inválida"),
email: z.string().email(),
telefone: z.string().min(1).refine((v) => /\(\d{2}\) \d{4,5}-\d{4}/.test(maskPhone(v)), "Telefone inválido"),
@@ -128,7 +160,7 @@
form.setFieldValue("nome", doc.nome as any);
form.setFieldValue("matricula", doc.matricula as any);
form.setFieldValue("cpf", maskCPF(doc.cpf) as any);
form.setFieldValue("rg", maskRG(doc.rg) as any);
form.setFieldValue("rg", (doc.rg || "") as any);
form.setFieldValue("nascimento", doc.nascimento as any);
form.setFieldValue("email", doc.email as any);
form.setFieldValue("telefone", maskPhone(doc.telefone) as any);
@@ -141,6 +173,20 @@
form.setFieldValue("admissaoData", (doc.admissaoData ?? "") as any);
}
async function fillFromCEP(cepMasked: string) {
const cep = onlyDigits(cepMasked);
if (cep.length !== 8) return;
try {
const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
const data = await res.json();
if (!data || data.erro) return;
const enderecoFull = [data.logradouro, data.bairro].filter(Boolean).join(", ");
form.setFieldValue("endereco", enderecoFull as any);
form.setFieldValue("cidade", (data.localidade || "") as any);
form.setFieldValue("uf", (data.uf || "") as any);
} catch {}
}
load();
</script>
@@ -177,6 +223,42 @@
</form.Field>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<form.Field name="cep" validators={{ onChange: schema.shape.cep }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">CEP <span class="text-error">*</span></span></label>
<input {name} value={state.value} inputmode="numeric" class="input input-bordered w-full" oninput={(e) => { const t=e.target as HTMLInputElement; const v=maskCEP(t.value); t.value=v; handleChange(v); }} onblur={(e) => fillFromCEP((e.target as HTMLInputElement).value)} required />
</div>
{/snippet}
</form.Field>
<form.Field name="cidade" validators={{ onChange: schema.shape.cidade }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">Cidade <span class="text-error">*</span></span></label>
<input {name} value={state.value} class="input input-bordered w-full" oninput={(e) => handleChange((e.target as HTMLInputElement).value)} required />
</div>
{/snippet}
</form.Field>
<form.Field name="uf" validators={{ onChange: schema.shape.uf }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">UF <span class="text-error">*</span></span></label>
<input {name} value={state.value} maxlength={2} class="input input-bordered w-full uppercase" oninput={(e) => handleChange((e.target as HTMLInputElement).value)} required />
</div>
{/snippet}
</form.Field>
</div>
<form.Field name="endereco" validators={{ onChange: schema.shape.endereco }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">Endereço <span class="text-error">*</span></span></label>
<input {name} value={state.value} class="input input-bordered w-full" oninput={(e) => handleChange((e.target as HTMLInputElement).value)} required />
</div>
{/snippet}
</form.Field>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<form.Field name="cpf" validators={{ onChange: schema.shape.cpf }}>
{#snippet children({ name, state, handleChange })}
@@ -190,7 +272,7 @@
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">RG <span class="text-error">*</span></span></label>
<input {name} value={state.value} inputmode="numeric" class="input input-bordered w-full" oninput={(e) => { const t=e.target as HTMLInputElement; const v=maskRG(t.value); t.value=v; handleChange(v); }} required />
<input {name} value={state.value} inputmode="numeric" class="input input-bordered w-full" oninput={(e) => { const t=e.target as HTMLInputElement; const v=onlyDigits(t.value); t.value=v; handleChange(v); }} required />
</div>
{/snippet}
</form.Field>
@@ -234,41 +316,6 @@
</form.Field>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<form.Field name="cep" validators={{ onChange: schema.shape.cep }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">CEP <span class="text-error">*</span></span></label>
<input {name} value={state.value} inputmode="numeric" class="input input-bordered w-full" oninput={(e) => { const t=e.target as HTMLInputElement; const v=maskCEP(t.value); t.value=v; handleChange(v); }} required />
</div>
{/snippet}
</form.Field>
<form.Field name="cidade" validators={{ onChange: schema.shape.cidade }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">Cidade <span class="text-error">*</span></span></label>
<input {name} value={state.value} class="input input-bordered w-full" oninput={(e) => handleChange((e.target as HTMLInputElement).value)} required />
</div>
{/snippet}
</form.Field>
<form.Field name="uf" validators={{ onChange: schema.shape.uf }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">UF <span class="text-error">*</span></span></label>
<input {name} value={state.value} maxlength={2} class="input input-bordered w-full uppercase" oninput={(e) => handleChange((e.target as HTMLInputElement).value)} required />
</div>
{/snippet}
</form.Field>
</div>
<form.Field name="endereco" validators={{ onChange: schema.shape.endereco }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">Endereço <span class="text-error">*</span></span></label>
<input {name} value={state.value} class="input input-bordered w-full" oninput={(e) => handleChange((e.target as HTMLInputElement).value)} required />
</div>
{/snippet}
</form.Field>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<form.Field name="simboloTipo" validators={{ onChange: schema.shape.simboloTipo }}>

View File

@@ -41,12 +41,58 @@
return d[9] === String(d1) && d[10] === String(d2);
}
function maskRG(v: string) {
const d = onlyDigits(v).slice(0, 9);
return d
.replace(/(\d{2})(\d)/, "$1.$2")
.replace(/(\d{3})(\d)/, "$1.$2")
.replace(/(\d{3})(\d{1})$/, "$1-$2");
const rgFormatByUF: Record<string, [number, number, number, number]> = {
RJ: [2, 3, 2, 1],
SP: [2, 3, 3, 1],
MG: [2, 3, 3, 1],
ES: [2, 3, 3, 1],
PR: [2, 3, 3, 1],
SC: [2, 3, 3, 1],
RS: [2, 3, 3, 1],
BA: [2, 3, 3, 1],
PE: [2, 3, 3, 1],
CE: [2, 3, 3, 1],
PA: [2, 3, 3, 1],
AM: [2, 3, 3, 1],
AC: [2, 3, 3, 1],
AP: [2, 3, 3, 1],
AL: [2, 3, 3, 1],
RN: [2, 3, 3, 1],
PB: [2, 3, 3, 1],
MA: [2, 3, 3, 1],
PI: [2, 3, 3, 1],
DF: [2, 3, 3, 1],
GO: [2, 3, 3, 1],
MT: [2, 3, 3, 1],
MS: [2, 3, 3, 1],
RO: [2, 3, 3, 1],
RR: [2, 3, 3, 1],
TO: [2, 3, 3, 1],
};
function maskRGByUF(uf: string, v: string) {
const raw = (v || "").toUpperCase().replace(/[^0-9X]/g, "");
const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1];
const baseMax = a + b + c;
const baseDigits = raw.replace(/X/g, "").slice(0, baseMax);
const verifier = raw.slice(baseDigits.length, baseDigits.length + dv).slice(0, 1);
const g1 = baseDigits.slice(0, a);
const g2 = baseDigits.slice(a, a + b);
const g3 = baseDigits.slice(a + b, a + b + c);
let out = g1;
if (g2) out += `.${g2}`;
if (g3) out += `.${g3}`;
if (verifier) out += `-${verifier}`;
return out;
}
function padRGLeftByUF(uf: string, v: string) {
const raw = (v || "").toUpperCase().replace(/[^0-9X]/g, "");
const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1];
const baseMax = a + b + c;
let base = raw.replace(/X/g, "");
const verifier = raw.slice(base.length, base.length + dv).slice(0, 1);
if (base.length < baseMax) base = base.padStart(baseMax, "0");
return maskRGByUF(uf, base + (verifier || ""));
}
function maskCEP(v: string) {
@@ -88,7 +134,7 @@
rg: z
.string()
.min(1, "Obrigatório")
.refine((v) => /^\d{2}\.\d{3}\.\d{3}-\d$/.test(maskRG(v)), "RG inválido"),
.refine((v) => /^\d+$/.test(v), "RG inválido"),
nascimento: z.string().refine(isValidDateBR, "Data inválida (dd/mm/aaaa)"),
email: z.string().email("E-mail inválido"),
telefone: z
@@ -161,9 +207,9 @@
setTimeout(() => goto("/recursos-humanos/funcionarios"), 600);
} catch (e: any) {
const msg = e?.message || String(e);
if (/CPF j[aá] cadastrado/i.test(msg)) formApi.setFieldError("cpf", "CPF já cadastrado");
if (/Matr[ií]cula j[aá] cadastrada/i.test(msg)) formApi.setFieldError("matricula", "Matrícula já cadastrada");
notice = { kind: "error", text: "Erro ao cadastrar funcionário." };
if (/CPF j[aá] cadastrado/i.test(msg)) notice = { kind: "error", text: "CPF já cadastrado." };
else if (/Matr[ií]cula j[aá] cadastrada/i.test(msg)) notice = { kind: "error", text: "Matrícula já cadastrada." };
else notice = { kind: "error", text: "Erro ao cadastrar funcionário." };
}
}
}));
@@ -171,6 +217,20 @@
let notice: { kind: "success" | "error"; text: string } | null = null;
loadSimbolos();
async function fillFromCEP(cepMasked: string) {
const cep = onlyDigits(cepMasked);
if (cep.length !== 8) return;
try {
const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
const data = await res.json();
if (!data || data.erro) return;
const enderecoFull = [data.logradouro, data.bairro].filter(Boolean).join(", ");
form.setFieldValue("endereco", enderecoFull as any);
form.setFieldValue("cidade", (data.localidade || "") as any);
form.setFieldValue("uf", (data.uf || "") as any);
} catch {}
}
</script>
<form
@@ -209,6 +269,42 @@
</form.Field>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<form.Field name="cep" validators={{ onChange: schema.shape.cep }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">CEP <span class="text-error">*</span></span></label>
<input {name} value={state.value} inputmode="numeric" class="input input-bordered w-full" oninput={(e) => { const t=e.target as HTMLInputElement; const v=maskCEP(t.value); t.value=v; handleChange(v); }} onblur={(e) => fillFromCEP((e.target as HTMLInputElement).value)} required />
</div>
{/snippet}
</form.Field>
<form.Field name="cidade" validators={{ onChange: schema.shape.cidade }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">Cidade <span class="text-error">*</span></span></label>
<input {name} value={state.value} class="input input-bordered w-full" oninput={(e) => handleChange((e.target as HTMLInputElement).value)} required />
</div>
{/snippet}
</form.Field>
<form.Field name="uf" validators={{ onChange: schema.shape.uf }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">UF <span class="text-error">*</span></span></label>
<input {name} value={state.value} maxlength={2} class="input input-bordered w-full uppercase" oninput={(e) => handleChange((e.target as HTMLInputElement).value)} required />
</div>
{/snippet}
</form.Field>
</div>
<form.Field name="endereco" validators={{ onChange: schema.shape.endereco }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">Endereço <span class="text-error">*</span></span></label>
<input {name} value={state.value} class="input input-bordered w-full" oninput={(e) => handleChange((e.target as HTMLInputElement).value)} required />
</div>
{/snippet}
</form.Field>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<form.Field name="cpf" validators={{ onChange: schema.shape.cpf }}>
{#snippet children({ name, state, handleChange })}
@@ -222,7 +318,7 @@
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">RG <span class="text-error">*</span></span></label>
<input {name} value={state.value} inputmode="numeric" class="input input-bordered w-full" oninput={(e) => { const t=e.target as HTMLInputElement; const v=maskRG(t.value); t.value=v; handleChange(v); }} required />
<input {name} value={state.value} inputmode="numeric" class="input input-bordered w-full" oninput={(e) => { const t=e.target as HTMLInputElement; const v=onlyDigits(t.value); t.value=v; handleChange(v); }} required />
</div>
{/snippet}
</form.Field>
@@ -266,48 +362,13 @@
</form.Field>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<form.Field name="cep" validators={{ onChange: schema.shape.cep }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">CEP <span class="text-error">*</span></span></label>
<input {name} value={state.value} inputmode="numeric" class="input input-bordered w-full" oninput={(e) => { const t=e.target as HTMLInputElement; const v=maskCEP(t.value); t.value=v; handleChange(v); }} required />
</div>
{/snippet}
</form.Field>
<form.Field name="cidade" validators={{ onChange: schema.shape.cidade }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">Cidade <span class="text-error">*</span></span></label>
<input {name} value={state.value} class="input input-bordered w-full" oninput={(e) => handleChange((e.target as HTMLInputElement).value)} required />
</div>
{/snippet}
</form.Field>
<form.Field name="uf" validators={{ onChange: schema.shape.uf }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">UF <span class="text-error">*</span></span></label>
<input {name} value={state.value} maxlength={2} class="input input-bordered w-full uppercase" oninput={(e) => handleChange((e.target as HTMLInputElement).value)} required />
</div>
{/snippet}
</form.Field>
</div>
<form.Field name="endereco" validators={{ onChange: schema.shape.endereco }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">Endereço <span class="text-error">*</span></span></label>
<input {name} value={state.value} class="input input-bordered w-full" oninput={(e) => handleChange((e.target as HTMLInputElement).value)} required />
</div>
{/snippet}
</form.Field>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<form.Field name="simboloTipo" validators={{ onChange: schema.shape.simboloTipo }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for={name}><span class="label-text font-medium">Símbolo Tipo <span class="text-error">*</span></span></label>
<select {name} class="select select-bordered w-full" bind:value={tipo} oninput={(e) => handleChange((e.target as HTMLSelectElement).value)} required>
<select {name} class="select select-bordered w-full" bind:value={tipo} oninput={(e) => handleChange((e.target as HTMLSelectElement).value as any)} required>
<option value="cargo_comissionado">Cargo comissionado</option>
<option value="funcao_gratificada">Função gratificada</option>
</select>

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
const client = useConvexClient();
let list: Array<any> = [];
let filtro = "";
let notice: { kind: "success" | "error"; text: string } | null = null;
let toDelete: { id: string; nome: string } | null = null;
let deletingId: string | null = null;
async function load() {
try {
list = await client.query(api.funcionarios.getAll, {} as any);
} catch (e) {
notice = { kind: "error", text: "Falha ao carregar funcionários." };
}
}
function openDeleteModal(id: string, nome: string) {
toDelete = { id, nome };
(document.getElementById("delete_modal_func_excluir") as HTMLDialogElement)?.showModal();
}
function closeDeleteModal() {
toDelete = null;
(document.getElementById("delete_modal_func_excluir") as HTMLDialogElement)?.close();
}
async function confirmDelete() {
if (!toDelete) return;
try {
deletingId = toDelete.id;
await client.mutation(api.funcionarios.remove, { id: toDelete.id } as any);
closeDeleteModal();
notice = { kind: "success", text: "Funcionário excluído com sucesso." };
await load();
} catch (e) {
notice = { kind: "error", text: "Erro ao excluir cadastro." };
} finally {
deletingId = null;
}
}
function back() { goto("/recursos-humanos/funcionarios"); }
onMount(() => { void load(); });
</script>
<div class="space-y-4">
{#if notice}
<div class="alert" class:alert-success={notice.kind === "success"} class:alert-error={notice.kind === "error"}>
<span>{notice.text}</span>
</div>
{/if}
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold">Excluir Funcionários</h2>
<button class="btn" onclick={back}>Voltar</button>
</div>
<div class="form-control max-w-sm">
<label class="label" for="func_excluir_busca"><span class="label-text">Buscar por nome/CPF/matrícula</span></label>
<input id="func_excluir_busca" class="input input-bordered" bind:value={filtro} />
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Nome</th>
<th>CPF</th>
<th>Matrícula</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each list.filter((f) => {
const q = (filtro || "").toLowerCase();
return !q || (f.nome || "").toLowerCase().includes(q) || (f.cpf || "").includes(q) || (f.matricula || "").toLowerCase().includes(q);
}) as f}
<tr>
<td>{f.nome}</td>
<td>{f.cpf}</td>
<td>{f.matricula}</td>
<td>
<button class="btn btn-error btn-sm" onclick={() => openDeleteModal(f._id, f.nome)}>Excluir</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<dialog id="delete_modal_func_excluir" 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>
</div>

View File

@@ -0,0 +1,154 @@
<script lang="ts">
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { onMount } from "svelte";
const client = useConvexClient();
type Row = { _id: string; nome: string; valor: number; count: number };
let rows: Array<Row> = [];
let isLoading = true;
let notice: { kind: "error" | "success"; text: string } | null = null;
onMount(async () => {
try {
const simbolos = await client.query(api.simbolos.getAll, {} as any);
const funcionarios = await client.query(api.funcionarios.getAll, {} as any);
const counts: Record<string, number> = {};
for (const f of funcionarios) counts[f.simboloId] = (counts[f.simboloId] ?? 0) + 1;
rows = simbolos.map((s: any) => ({
_id: String(s._id),
nome: s.nome as string,
valor: Number(s.valor || 0),
count: counts[String(s._id)] ?? 0,
}));
} catch (e) {
notice = { kind: "error", text: "Falha ao carregar dados de relatórios." };
} finally {
isLoading = false;
}
});
const chartWidth = 900;
const chartHeight = 340;
const padding = { top: 10, right: 16, bottom: 80, left: 48 };
function getMax<T>(arr: Array<T>, sel: (t: T) => number): number {
let m = 0;
for (const a of arr) m = Math.max(m, sel(a));
return m;
}
function scaleY(v: number, max: number): number {
if (max <= 0) return 0;
const innerH = chartHeight - padding.top - padding.bottom;
return (v / max) * innerH;
}
function barX(i: number, n: number): number {
const innerW = chartWidth - padding.left - padding.right;
return padding.left + (innerW / n) * i + 10;
}
function barW(n: number): number {
const innerW = chartWidth - padding.left - padding.right;
return Math.max(8, innerW / n - 20);
}
let hover: { x: number; y: number; text: string } | null = null;
</script>
<div class="space-y-6 pb-32">
{#if notice}
<div class="alert" class:alert-error={notice.kind === "error"} class:alert-success={notice.kind === "success"}>
<span>{notice.text}</span>
</div>
{/if}
<div class="flex items-center justify-between">
<h2 class="text-3xl font-bold text-brand-dark">Relatórios</h2>
</div>
{#if isLoading}
<div class="flex justify-center items-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<div class="grid gap-8">
<!-- Gráfico 1: Símbolo x Salário (Valor) -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="card-title">Símbolo x Salário (Valor total)</h3>
<div class="overflow-x-auto">
<svg width={chartWidth} height={chartHeight} role="img" aria-label="Gráfico de barras: salário por símbolo">
{#if rows.length === 0}
<text x="16" y="32" class="opacity-60">Sem dados</text>
{:else}
{@const max = getMax(rows, (r) => r.valor)}
<!-- Eixo X (nomes) -->
{#each rows as r, i}
<text x={barX(i, rows.length) + barW(rows.length) / 2} y={chartHeight - padding.bottom + 28} text-anchor="middle" class="text-xs">
{r.nome}
</text>
{/each}
<!-- Barras -->
{#each rows as r, i}
<rect
x={barX(i, rows.length)}
y={chartHeight - padding.bottom - scaleY(r.valor, max)}
width={barW(rows.length)}
height={scaleY(r.valor, max)}
class="fill-primary/80 hover:fill-primary"
onmousemove={(e) => (hover = { x: e.offsetX, y: e.offsetY - 8, text: `${r.nome}: R$ ${r.valor.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}` })}
onmouseleave={() => (hover = null)}
/>
{/each}
{#if hover}
<foreignObject x={hover.x} y={hover.y} width="220" height="40">
<div class="badge badge-primary text-xs px-3 py-2 shadow">{hover.text}</div>
</foreignObject>
{/if}
{/if}
</svg>
</div>
</div>
</div>
<!-- Gráfico 2: Quantidade de Funcionários por Símbolo -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="card-title">Quantidade de Funcionários por Símbolo</h3>
<div class="overflow-x-auto">
<svg width={chartWidth} height={chartHeight} role="img" aria-label="Gráfico de barras: quantidade por símbolo">
{#if rows.length === 0}
<text x="16" y="32" class="opacity-60">Sem dados</text>
{:else}
{@const maxC = getMax(rows, (r) => r.count)}
{#each rows as r, i}
<text x={barX(i, rows.length) + barW(rows.length) / 2} y={chartHeight - padding.bottom + 28} text-anchor="middle" class="text-xs">
{r.nome}
</text>
{/each}
{#each rows as r, i}
<rect
x={barX(i, rows.length)}
y={chartHeight - padding.bottom - scaleY(r.count, Math.max(1, maxC))}
width={barW(rows.length)}
height={scaleY(r.count, Math.max(1, maxC))}
class="fill-secondary/80 hover:fill-secondary"
onmousemove={(e) => (hover = { x: e.offsetX, y: e.offsetY - 8, text: `${r.nome}: ${r.count} funcionário(s)` })}
onmouseleave={() => (hover = null)}
/>
{/each}
{#if hover}
<foreignObject x={hover.x} y={hover.y} width="200" height="40">
<div class="badge badge-secondary text-xs px-3 py-2 shadow">{hover.text}</div>
</foreignObject>
{/if}
{/if}
</svg>
</div>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -1,10 +1,37 @@
<script lang="ts">
import { useQuery, useConvexClient } from "convex-svelte";
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
import { onMount } from "svelte";
const client = useConvexClient();
const simbolosQuery = useQuery(api.simbolos.getAll, {});
let isLoading = true;
let list: Array<any> = [];
let filtroNome = "";
let filtroTipo: "" | "cargo_comissionado" | "funcao_gratificada" = "";
let filtroDescricao = "";
let filtered: Array<any> = [];
let notice: { kind: "success" | "error"; text: string } | null = null;
$: needsScroll = filtered.length > 8;
let openMenuId: string | null = null;
function toggleMenu(id: string) {
openMenuId = openMenuId === id ? null : id;
}
$: filtered = (list ?? []).filter((s) => {
const nome = (filtroNome || "").toLowerCase();
const desc = (filtroDescricao || "").toLowerCase();
const okNome = !nome || (s.nome || "").toLowerCase().includes(nome);
const okDesc = !desc || (s.descricao || "").toLowerCase().includes(desc);
const okTipo = !filtroTipo || s.tipo === filtroTipo;
return okNome && okDesc && okTipo;
});
onMount(async () => {
try {
list = await client.query(api.simbolos.getAll, {} as any);
} finally {
isLoading = false;
}
});
let deletingId: Id<"simbolos"> | null = null;
let simboloToDelete: { id: Id<"simbolos">; nome: string } | null = null;
@@ -21,14 +48,15 @@
async function confirmDelete() {
if (!simboloToDelete) return;
try {
deletingId = simboloToDelete.id;
await client.mutation(api.simbolos.remove, { id: simboloToDelete.id });
// reload list
list = await client.query(api.simbolos.getAll, {} as any);
notice = { kind: "success", text: "Símbolo excluído com sucesso." };
closeDeleteModal();
} catch (error) {
console.error("Erro ao excluir símbolo:", error);
alert("Erro ao excluir símbolo. Tente novamente.");
notice = { kind: "error", text: "Erro ao excluir símbolo." };
} finally {
deletingId = null;
}
@@ -46,6 +74,11 @@
</script>
<div class="space-y-6 pb-32">
{#if notice}
<div class="alert" class:alert-success={notice.kind === "success"} class:alert-error={notice.kind === "error"}>
<span>{notice.text}</span>
</div>
{/if}
<div class="flex justify-between items-center">
<h2 class="text-3xl font-bold text-brand-dark">Símbolos</h2>
<a href="/recursos-humanos/simbolos/cadastro" class="btn btn-primary">
@@ -65,12 +98,31 @@
</a>
</div>
{#if simbolosQuery.isLoading}
<div class="grid md:grid-cols-3 gap-3 items-end">
<div class="form-control">
<label class="label" for="symbol_nome"><span class="label-text">Nome</span></label>
<input id="symbol_nome" class="input input-bordered" bind:value={filtroNome} />
</div>
<div class="form-control">
<label class="label" for="symbol_tipo"><span class="label-text">Tipo</span></label>
<select id="symbol_tipo" class="select select-bordered" bind:value={filtroTipo}>
<option value="">Todos</option>
<option value="cargo_comissionado">Cargo comissionado</option>
<option value="funcao_gratificada">Função gratificada</option>
</select>
</div>
<div class="form-control">
<label class="label" for="symbol_desc"><span class="label-text">Descrição</span></label>
<input id="symbol_desc" class="input input-bordered" bind:value={filtroDescricao} />
</div>
</div>
{#if isLoading}
<div class="flex justify-center items-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if simbolosQuery.data && simbolosQuery.data.length > 0}
<div class="overflow-x-auto bg-base-100 rounded-lg shadow-sm mb-8">
{:else}
<div class="overflow-x-auto bg-base-100 rounded-lg shadow-sm mb-2" class:overflow-y-auto={needsScroll} style={needsScroll ? "max-height: calc(100vh - 180px);" : "overflow-y: visible;"}>
<table class="table table-zebra">
<thead>
<tr>
@@ -84,8 +136,9 @@
</tr>
</thead>
<tbody>
{#each simbolosQuery.data as simbolo}
<tr class="hover">
{#if filtered.length > 0}
{#each filtered as simbolo}
<tr class="hover">
<td class="font-medium">{simbolo.nome}</td>
<td>
<span
@@ -101,8 +154,8 @@
<td class="font-semibold">{formatMoney(simbolo.valor)}</td>
<td class="max-w-xs truncate">{simbolo.descricao}</td>
<td class="text-right">
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<div class="dropdown dropdown-end" class:dropdown-open={openMenuId === simbolo._id}>
<button type="button" class="btn btn-ghost btn-sm" onclick={() => toggleMenu(simbolo._id)}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
@@ -113,13 +166,10 @@
d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z"
/>
</svg>
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg border border-base-300"
>
</button>
<ul class="dropdown-content menu bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg border border-base-300">
<li>
<a href="/recursos-humanos/simbolos/{simbolo._id}/editar">
<a href={"/recursos-humanos/simbolos/" + simbolo._id + "/editar"}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
@@ -134,10 +184,7 @@
</a>
</li>
<li>
<button
on:click={() => openDeleteModal(simbolo._id, simbolo.nome)}
class="text-error"
>
<button type="button" onclick={() => openDeleteModal(simbolo._id, simbolo.nome)} class="text-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
@@ -156,28 +203,16 @@
</ul>
</div>
</td>
</tr>
{/each}
{:else}
<tr>
<td colspan="7" class="text-center opacity-70">Nenhum símbolo encontrado com os filtros atuais.</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
{:else}
<div class="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info shrink-0 w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>Nenhum símbolo encontrado. Crie um novo para começar.</span>
</div>
{/if}
</div>
@@ -210,12 +245,12 @@
{/if}
<div class="modal-action">
<form method="dialog" class="flex gap-2">
<button class="btn btn-ghost" on:click={closeDeleteModal} type="button">
<button class="btn btn-ghost" onclick={closeDeleteModal} type="button">
Cancelar
</button>
<button
class="btn btn-error"
on:click={confirmDelete}
onclick={confirmDelete}
disabled={deletingId !== null}
type="button"
>

View File

@@ -3,7 +3,6 @@
import { api } from "@sgse-app/backend/convex/_generated/api";
import { createForm } from "@tanstack/svelte-form";
import z from "zod";
import { Plus } from "lucide-svelte";
import { goto } from "$app/navigation";
import type { SimboloTipo } from "@sgse-app/backend/convex/schema";
@@ -370,7 +369,6 @@
class:loading={isSubmitting}
disabled={isSubmitting || !canSubmit}
>
<Plus class="h-5 w-5" />
<span>Cadastrar Símbolo</span>
</button>
</div>

View File

@@ -177,3 +177,12 @@ export const update = mutation({
return null;
},
});
export const remove = mutation({
args: { id: v.id("funcionarios") },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.delete(args.id);
return null;
},
});