feat: add employee management features including filtering, deletion, and improved layout
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user