Feat cadastro funcinarios #2
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="aqua">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<main
|
<main
|
||||||
id="container-central"
|
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()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,39 @@
|
|||||||
|
|
||||||
const onlyDigits = (s: string) => (s || "").replace(/\D/g, "");
|
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 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 maskCEP = (v: string) => onlyDigits(v).slice(0, 8).replace(/(\d{5})(\d{1,3})$/, "$1-$2");
|
||||||
const maskPhone = (v: string) => {
|
const maskPhone = (v: string) => {
|
||||||
const d = onlyDigits(v).slice(0, 11);
|
const d = onlyDigits(v).slice(0, 11);
|
||||||
@@ -45,7 +77,7 @@
|
|||||||
nome: z.string().min(3),
|
nome: z.string().min(3),
|
||||||
matricula: z.string().min(1),
|
matricula: z.string().min(1),
|
||||||
cpf: z.string().min(1).refine(isValidCPF, "CPF inválido"),
|
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"),
|
nascimento: z.string().refine(isValidDateBR, "Data inválida"),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
telefone: z.string().min(1).refine((v) => /\(\d{2}\) \d{4,5}-\d{4}/.test(maskPhone(v)), "Telefone inválido"),
|
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("nome", doc.nome as any);
|
||||||
form.setFieldValue("matricula", doc.matricula as any);
|
form.setFieldValue("matricula", doc.matricula as any);
|
||||||
form.setFieldValue("cpf", maskCPF(doc.cpf) 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("nascimento", doc.nascimento as any);
|
||||||
form.setFieldValue("email", doc.email as any);
|
form.setFieldValue("email", doc.email as any);
|
||||||
form.setFieldValue("telefone", maskPhone(doc.telefone) as any);
|
form.setFieldValue("telefone", maskPhone(doc.telefone) as any);
|
||||||
@@ -141,6 +173,20 @@
|
|||||||
form.setFieldValue("admissaoData", (doc.admissaoData ?? "") as any);
|
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();
|
load();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -177,6 +223,42 @@
|
|||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</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">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<form.Field name="cpf" validators={{ onChange: schema.shape.cpf }}>
|
<form.Field name="cpf" validators={{ onChange: schema.shape.cpf }}>
|
||||||
{#snippet children({ name, state, handleChange })}
|
{#snippet children({ name, state, handleChange })}
|
||||||
@@ -190,7 +272,7 @@
|
|||||||
{#snippet children({ name, state, handleChange })}
|
{#snippet children({ name, state, handleChange })}
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for={name}><span class="label-text font-medium">RG <span class="text-error">*</span></span></label>
|
<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>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
@@ -234,41 +316,6 @@
|
|||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</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">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<form.Field name="simboloTipo" validators={{ onChange: schema.shape.simboloTipo }}>
|
<form.Field name="simboloTipo" validators={{ onChange: schema.shape.simboloTipo }}>
|
||||||
|
|||||||
@@ -41,12 +41,58 @@
|
|||||||
return d[9] === String(d1) && d[10] === String(d2);
|
return d[9] === String(d1) && d[10] === String(d2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function maskRG(v: string) {
|
const rgFormatByUF: Record<string, [number, number, number, number]> = {
|
||||||
const d = onlyDigits(v).slice(0, 9);
|
RJ: [2, 3, 2, 1],
|
||||||
return d
|
SP: [2, 3, 3, 1],
|
||||||
.replace(/(\d{2})(\d)/, "$1.$2")
|
MG: [2, 3, 3, 1],
|
||||||
.replace(/(\d{3})(\d)/, "$1.$2")
|
ES: [2, 3, 3, 1],
|
||||||
.replace(/(\d{3})(\d{1})$/, "$1-$2");
|
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) {
|
function maskCEP(v: string) {
|
||||||
@@ -88,7 +134,7 @@
|
|||||||
rg: z
|
rg: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "Obrigatório")
|
.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)"),
|
nascimento: z.string().refine(isValidDateBR, "Data inválida (dd/mm/aaaa)"),
|
||||||
email: z.string().email("E-mail inválido"),
|
email: z.string().email("E-mail inválido"),
|
||||||
telefone: z
|
telefone: z
|
||||||
@@ -161,9 +207,9 @@
|
|||||||
setTimeout(() => goto("/recursos-humanos/funcionarios"), 600);
|
setTimeout(() => goto("/recursos-humanos/funcionarios"), 600);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.message || String(e);
|
const msg = e?.message || String(e);
|
||||||
if (/CPF j[aá] cadastrado/i.test(msg)) formApi.setFieldError("cpf", "CPF já cadastrado");
|
if (/CPF j[aá] cadastrado/i.test(msg)) notice = { kind: "error", text: "CPF já cadastrado." };
|
||||||
if (/Matr[ií]cula j[aá] cadastrada/i.test(msg)) formApi.setFieldError("matricula", "Matrícula já cadastrada");
|
else if (/Matr[ií]cula j[aá] cadastrada/i.test(msg)) notice = { kind: "error", text: "Matrícula já cadastrada." };
|
||||||
notice = { kind: "error", text: "Erro ao cadastrar funcionário." };
|
else notice = { kind: "error", text: "Erro ao cadastrar funcionário." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -171,6 +217,20 @@
|
|||||||
let notice: { kind: "success" | "error"; text: string } | null = null;
|
let notice: { kind: "success" | "error"; text: string } | null = null;
|
||||||
|
|
||||||
loadSimbolos();
|
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>
|
</script>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@@ -209,6 +269,42 @@
|
|||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</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">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<form.Field name="cpf" validators={{ onChange: schema.shape.cpf }}>
|
<form.Field name="cpf" validators={{ onChange: schema.shape.cpf }}>
|
||||||
{#snippet children({ name, state, handleChange })}
|
{#snippet children({ name, state, handleChange })}
|
||||||
@@ -222,7 +318,7 @@
|
|||||||
{#snippet children({ name, state, handleChange })}
|
{#snippet children({ name, state, handleChange })}
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for={name}><span class="label-text font-medium">RG <span class="text-error">*</span></span></label>
|
<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>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
@@ -266,48 +362,13 @@
|
|||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</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">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<form.Field name="simboloTipo" validators={{ onChange: schema.shape.simboloTipo }}>
|
<form.Field name="simboloTipo" validators={{ onChange: schema.shape.simboloTipo }}>
|
||||||
{#snippet children({ name, state, handleChange })}
|
{#snippet children({ name, state, handleChange })}
|
||||||
<div class="form-control">
|
<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>
|
<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="cargo_comissionado">Cargo comissionado</option>
|
||||||
<option value="funcao_gratificada">Função gratificada</option>
|
<option value="funcao_gratificada">Função gratificada</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +1,37 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQuery, useConvexClient } from "convex-svelte";
|
import { useConvexClient } from "convex-svelte";
|
||||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
const client = useConvexClient();
|
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 deletingId: Id<"simbolos"> | null = null;
|
||||||
let simboloToDelete: { id: Id<"simbolos">; nome: string } | null = null;
|
let simboloToDelete: { id: Id<"simbolos">; nome: string } | null = null;
|
||||||
@@ -21,14 +48,15 @@
|
|||||||
|
|
||||||
async function confirmDelete() {
|
async function confirmDelete() {
|
||||||
if (!simboloToDelete) return;
|
if (!simboloToDelete) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
deletingId = simboloToDelete.id;
|
deletingId = simboloToDelete.id;
|
||||||
await client.mutation(api.simbolos.remove, { id: 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();
|
closeDeleteModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao excluir símbolo:", error);
|
notice = { kind: "error", text: "Erro ao excluir símbolo." };
|
||||||
alert("Erro ao excluir símbolo. Tente novamente.");
|
|
||||||
} finally {
|
} finally {
|
||||||
deletingId = null;
|
deletingId = null;
|
||||||
}
|
}
|
||||||
@@ -46,6 +74,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6 pb-32">
|
<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">
|
<div class="flex justify-between items-center">
|
||||||
<h2 class="text-3xl font-bold text-brand-dark">Símbolos</h2>
|
<h2 class="text-3xl font-bold text-brand-dark">Símbolos</h2>
|
||||||
<a href="/recursos-humanos/simbolos/cadastro" class="btn btn-primary">
|
<a href="/recursos-humanos/simbolos/cadastro" class="btn btn-primary">
|
||||||
@@ -65,12 +98,31 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
<div class="flex justify-center items-center py-12">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
{:else if simbolosQuery.data && simbolosQuery.data.length > 0}
|
{:else}
|
||||||
<div class="overflow-x-auto bg-base-100 rounded-lg shadow-sm mb-8">
|
<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">
|
<table class="table table-zebra">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -84,8 +136,9 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each simbolosQuery.data as simbolo}
|
{#if filtered.length > 0}
|
||||||
<tr class="hover">
|
{#each filtered as simbolo}
|
||||||
|
<tr class="hover">
|
||||||
<td class="font-medium">{simbolo.nome}</td>
|
<td class="font-medium">{simbolo.nome}</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
@@ -101,8 +154,8 @@
|
|||||||
<td class="font-semibold">{formatMoney(simbolo.valor)}</td>
|
<td class="font-semibold">{formatMoney(simbolo.valor)}</td>
|
||||||
<td class="max-w-xs truncate">{simbolo.descricao}</td>
|
<td class="max-w-xs truncate">{simbolo.descricao}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="dropdown dropdown-end">
|
<div class="dropdown dropdown-end" class:dropdown-open={openMenuId === simbolo._id}>
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
|
<button type="button" class="btn btn-ghost btn-sm" onclick={() => toggleMenu(simbolo._id)}>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-5 w-5"
|
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"
|
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>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
<ul
|
<ul class="dropdown-content menu bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg border border-base-300">
|
||||||
tabindex="0"
|
|
||||||
class="dropdown-content menu bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg border border-base-300"
|
|
||||||
>
|
|
||||||
<li>
|
<li>
|
||||||
<a href="/recursos-humanos/simbolos/{simbolo._id}/editar">
|
<a href={"/recursos-humanos/simbolos/" + simbolo._id + "/editar"}>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -134,10 +184,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button type="button" onclick={() => openDeleteModal(simbolo._id, simbolo.nome)} class="text-error">
|
||||||
on:click={() => openDeleteModal(simbolo._id, simbolo.nome)}
|
|
||||||
class="text-error"
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -156,28 +203,16 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center opacity-70">Nenhum símbolo encontrado com os filtros atuais.</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -210,12 +245,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<form method="dialog" class="flex gap-2">
|
<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
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-error"
|
class="btn btn-error"
|
||||||
on:click={confirmDelete}
|
onclick={confirmDelete}
|
||||||
disabled={deletingId !== null}
|
disabled={deletingId !== null}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
import { createForm } from "@tanstack/svelte-form";
|
import { createForm } from "@tanstack/svelte-form";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { Plus } from "lucide-svelte";
|
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { SimboloTipo } from "@sgse-app/backend/convex/schema";
|
import type { SimboloTipo } from "@sgse-app/backend/convex/schema";
|
||||||
|
|
||||||
@@ -370,7 +369,6 @@
|
|||||||
class:loading={isSubmitting}
|
class:loading={isSubmitting}
|
||||||
disabled={isSubmitting || !canSubmit}
|
disabled={isSubmitting || !canSubmit}
|
||||||
>
|
>
|
||||||
<Plus class="h-5 w-5" />
|
|
||||||
<span>Cadastrar Símbolo</span>
|
<span>Cadastrar Símbolo</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -177,3 +177,12 @@ export const update = mutation({
|
|||||||
return null;
|
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user