From be3522ae749cacd2f63e750bcd6426ae71f011c8 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Fri, 24 Oct 2025 17:25:46 -0300 Subject: [PATCH] feat: implement employee registration form with validation and data handling --- .../(dashboard)/recursos-humanos/+page.svelte | 2 +- .../[funcionarioId]/editar/+page.svelte | 317 ++++++++++++++++ .../funcionarios/cadastro/+page.svelte | 349 ++++++++++++++++++ packages/backend/convex/funcionarios.ts | 157 +++++++- packages/backend/convex/schema.ts | 46 ++- 5 files changed, 855 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/editar/+page.svelte diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte index fdf04dc..0eac597 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte @@ -13,7 +13,7 @@ >Cadastrar Funcionários Editar Cadastro + import { useConvexClient } from "convex-svelte"; + import { api } from "@sgse-app/backend/convex/_generated/api"; + import { createForm } from "@tanstack/svelte-form"; + import z from "zod"; + import { goto } from "$app/navigation"; + import { page } from "$app/stores"; + import type { SimboloTipo } from "@sgse-app/backend/convex/schema"; + + const client = useConvexClient(); + $: funcionarioId = $page.params.funcionarioId as string; + + let simbolos: Array<{ _id: string; nome: string; tipo: SimboloTipo; descricao: string }> = []; + let tipo: SimboloTipo = "cargo_comissionado"; + + 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 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); + if (d.length <= 10) return d.replace(/(\d{2})(\d)/, "($1) $2").replace(/(\d{4})(\d{1,4})$/, "$1-$2"); + return d.replace(/(\d{2})(\d)/, "($1) $2").replace(/(\d{5})(\d{1,4})$/, "$1-$2"); + }; + const maskDate = (v: string) => onlyDigits(v).slice(0, 8).replace(/(\d{2})(\d)/, "$1/$2").replace(/(\d{2})(\d{1,4})$/, "$1/$2"); + const isValidDateBR = (v: string) => { + const m = v.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + if (!m) return false; + const dd = Number(m[1]), mm = Number(m[2]) - 1, yyyy = Number(m[3]); + const dt = new Date(yyyy, mm, dd); + return dt.getFullYear() === yyyy && dt.getMonth() === mm && dt.getDate() === dd; + }; + const isValidCPF = (raw: string) => { + const d = onlyDigits(raw); + if (d.length !== 11 || /^([0-9])\1+$/.test(d)) return false; + const calc = (base: string, factor: number) => { + let sum = 0; for (let i = 0; i < base.length; i++) sum += parseInt(base[i]) * (factor - i); + const rest = (sum * 10) % 11; return rest === 10 ? 0 : rest; + }; + const d1 = calc(d.slice(0, 9), 10); const d2 = calc(d.slice(0, 10), 11); + return d[9] === String(d1) && d[10] === String(d2); + }; + + const schema = z.object({ + 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"), + 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"), + endereco: z.string().min(1), + cep: z.string().min(1).refine((v) => /^\d{5}-\d{3}$/.test(maskCEP(v)), "CEP inválido"), + cidade: z.string().min(1), + uf: z.string().length(2).transform((s) => s.toUpperCase()), + simboloTipo: z.enum(["cargo_comissionado", "funcao_gratificada"]), + simboloId: z.string().min(1), + admissaoData: z.string().refine(isValidDateBR, "Data inválida"), + }).superRefine((val, ctx) => { + if (val.cep && (!val.cidade || !val.uf)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["cidade"], message: "Cidade obrigatória" }); + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["uf"], message: "UF obrigatória" }); + } + }); + + let notice: { kind: "success" | "error"; text: string } | null = null; + + const form = createForm(() => ({ + defaultValues: { + nome: "", + matricula: "", + cpf: "", + rg: "", + nascimento: "", + email: "", + telefone: "", + endereco: "", + cep: "", + cidade: "", + uf: "", + simboloTipo: tipo as SimboloTipo, + simboloId: "", + admissaoData: "", + }, + onSubmit: async ({ value, formApi }) => { + const payload = { + id: funcionarioId as any, + nome: value.nome, + matricula: value.matricula, + simboloId: value.simboloId as any, + nascimento: value.nascimento, + rg: onlyDigits(value.rg), + cpf: onlyDigits(value.cpf), + endereco: value.endereco, + cep: onlyDigits(value.cep), + cidade: value.cidade, + uf: value.uf.toUpperCase(), + telefone: onlyDigits(value.telefone), + email: value.email, + admissaoData: value.admissaoData, + desligamentoData: undefined, + simboloTipo: value.simboloTipo as SimboloTipo, + }; + + try { + await client.mutation(api.funcionarios.update, payload as any); + notice = { kind: "success", text: "Cadastro atualizado com sucesso." }; + setTimeout(() => goto("/recursos-humanos/funcionarios"), 600); + } catch (e: any) { + const msg = e?.message || String(e); + 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 atualizar cadastro." }; + } + } + })); + + async function load() { + const list = await client.query(api.simbolos.getAll, {} as any); + simbolos = list.map((s: any) => ({ _id: s._id, nome: s.nome, tipo: s.tipo, descricao: s.descricao })); + const doc = await client.query(api.funcionarios.getById, { id: funcionarioId as any }); + if (!doc) { + notice = { kind: "error", text: "Funcionário não encontrado." }; + return; + } + tipo = doc.simboloTipo as SimboloTipo; + // set defaults + 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("nascimento", doc.nascimento as any); + form.setFieldValue("email", doc.email as any); + form.setFieldValue("telefone", maskPhone(doc.telefone) as any); + form.setFieldValue("endereco", doc.endereco as any); + form.setFieldValue("cep", maskCEP(doc.cep) as any); + form.setFieldValue("cidade", doc.cidade as any); + form.setFieldValue("uf", doc.uf as any); + form.setFieldValue("simboloTipo", doc.simboloTipo as any); + form.setFieldValue("simboloId", (doc.simboloId as unknown as string) as any); + form.setFieldValue("admissaoData", (doc.admissaoData ?? "") as any); + } + + load(); + + +
{ e.preventDefault(); e.stopPropagation(); form.handleSubmit(); }}> +
+
+ {#if notice} +
+ {notice.text} +
+ {/if} + +
+

Editar Cadastro

+

Atualize os dados do funcionário.

+
+ +
+ + {#snippet children({ name, state, handleChange })} +
+ + handleChange((e.target as HTMLInputElement).value)} required /> +
+ {/snippet} +
+ + {#snippet children({ name, state, handleChange })} +
+ + handleChange((e.target as HTMLInputElement).value)} required /> +
+ {/snippet} +
+
+ +
+ + {#snippet children({ name, state, handleChange })} +
+ + { const t=e.target as HTMLInputElement; const v=maskCPF(t.value); t.value=v; handleChange(v); }} required /> +
+ {/snippet} +
+ + {#snippet children({ name, state, handleChange })} +
+ + { const t=e.target as HTMLInputElement; const v=maskRG(t.value); t.value=v; handleChange(v); }} required /> +
+ {/snippet} +
+
+ +
+ + {#snippet children({ name, state, handleChange })} +
+ + { const t=e.target as HTMLInputElement; const v=maskDate(t.value); t.value=v; handleChange(v); }} required /> +
+ {/snippet} +
+ + {#snippet children({ name, state, handleChange })} +
+ + { const t=e.target as HTMLInputElement; const v=maskDate(t.value); t.value=v; handleChange(v); }} required /> +
+ {/snippet} +
+
+ +
+ + {#snippet children({ name, state, handleChange })} +
+ + handleChange((e.target as HTMLInputElement).value)} required /> +
+ {/snippet} +
+ + {#snippet children({ name, state, handleChange })} +
+ + { const t=e.target as HTMLInputElement; const v=maskPhone(t.value); t.value=v; handleChange(v); }} required /> +
+ {/snippet} +
+
+ +
+ + {#snippet children({ name, state, handleChange })} +
+ + { const t=e.target as HTMLInputElement; const v=maskCEP(t.value); t.value=v; handleChange(v); }} required /> +
+ {/snippet} +
+ + {#snippet children({ name, state, handleChange })} +
+ + handleChange((e.target as HTMLInputElement).value)} required /> +
+ {/snippet} +
+ + {#snippet children({ name, state, handleChange })} +
+ + handleChange((e.target as HTMLInputElement).value)} required /> +
+ {/snippet} +
+
+ + + {#snippet children({ name, state, handleChange })} +
+ + handleChange((e.target as HTMLInputElement).value)} required /> +
+ {/snippet} +
+ +
+ + {#snippet children({ name, state, handleChange })} +
+ + +
+ {/snippet} +
+ + + {#snippet children({ name, state, handleChange })} +
+ + +
+ {/snippet} +
+
+ + ({ canSubmit: s.canSubmit, isSubmitting: s.isSubmitting })}> + {#snippet children({ canSubmit, isSubmitting })} +
+ + +
+ {/snippet} +
+
+
+
+ + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/cadastro/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/cadastro/+page.svelte index e69de29..7b6545e 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/cadastro/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/cadastro/+page.svelte @@ -0,0 +1,349 @@ + + +
{ e.preventDefault(); e.stopPropagation(); form.handleSubmit(); }} +> +
+
+ {#if notice} +
+ {notice.text} +
+ {/if} + +
+

Cadastro de Funcionários

+

Preencha os campos abaixo para cadastrar um novo funcionário.

+
+ +
+ + {#snippet children({ name, state, handleChange })} +
+ + handleChange((e.target as HTMLInputElement).value)} required /> +
+ {/snippet} +
+ + {#snippet children({ name, state, handleChange })} +
+ + handleChange((e.target as HTMLInputElement).value)} required /> +
+ {/snippet} +
+
+ +
+ + {#snippet children({ name, state, handleChange })} +
+ + { const t=e.target as HTMLInputElement; const v=maskCPF(t.value); t.value=v; handleChange(v); }} required /> +
+ {/snippet} +
+ + {#snippet children({ name, state, handleChange })} +
+ + { const t=e.target as HTMLInputElement; const v=maskRG(t.value); t.value=v; handleChange(v); }} required /> +
+ {/snippet} +
+
+ +
+ + {#snippet children({ name, state, handleChange })} +
+ + { const t=e.target as HTMLInputElement; const v=maskDate(t.value); t.value=v; handleChange(v); }} required /> +
+ {/snippet} +
+ + {#snippet children({ name, state, handleChange })} +
+ + { const t=e.target as HTMLInputElement; const v=maskDate(t.value); t.value=v; handleChange(v); }} required /> +
+ {/snippet} +
+
+ +
+ + {#snippet children({ name, state, handleChange })} +
+ + handleChange((e.target as HTMLInputElement).value)} required /> +
+ {/snippet} +
+ + {#snippet children({ name, state, handleChange })} +
+ + { const t=e.target as HTMLInputElement; const v=maskPhone(t.value); t.value=v; handleChange(v); }} required /> +
+ {/snippet} +
+
+ +
+ + {#snippet children({ name, state, handleChange })} +
+ + { const t=e.target as HTMLInputElement; const v=maskCEP(t.value); t.value=v; handleChange(v); }} required /> +
+ {/snippet} +
+ + {#snippet children({ name, state, handleChange })} +
+ + handleChange((e.target as HTMLInputElement).value)} required /> +
+ {/snippet} +
+ + {#snippet children({ name, state, handleChange })} +
+ + handleChange((e.target as HTMLInputElement).value)} required /> +
+ {/snippet} +
+
+ + + {#snippet children({ name, state, handleChange })} +
+ + handleChange((e.target as HTMLInputElement).value)} required /> +
+ {/snippet} +
+ +
+ + {#snippet children({ name, state, handleChange })} +
+ + +
+ {/snippet} +
+ + + {#snippet children({ name, state, handleChange })} +
+ + +
+ {/snippet} +
+
+ + ({ canSubmit: s.canSubmit, isSubmitting: s.isSubmitting })}> + {#snippet children({ canSubmit, isSubmitting })} +
+ + +
+ {/snippet} +
+
+
+
+ + diff --git a/packages/backend/convex/funcionarios.ts b/packages/backend/convex/funcionarios.ts index 753e307..2ff4155 100644 --- a/packages/backend/convex/funcionarios.ts +++ b/packages/backend/convex/funcionarios.ts @@ -1,24 +1,179 @@ import { v } from "convex/values"; import { query, mutation } from "./_generated/server"; +import { simboloTipo } from "./schema"; export const getAll = query({ + args: {}, + returns: v.array( + v.object({ + _id: v.id("funcionarios"), + _creationTime: v.number(), + nome: v.string(), + nascimento: v.string(), + rg: v.string(), + cpf: v.string(), + endereco: v.string(), + cep: v.string(), + cidade: v.string(), + uf: v.string(), + telefone: v.string(), + email: v.string(), + matricula: v.string(), + admissaoData: v.optional(v.string()), + desligamentoData: v.optional(v.string()), + simboloId: v.id("simbolos"), + simboloTipo: simboloTipo, + }) + ), handler: async (ctx) => { return await ctx.db.query("funcionarios").collect(); }, }); +export const getById = query({ + args: { id: v.id("funcionarios") }, + returns: v.union( + v.object({ + _id: v.id("funcionarios"), + _creationTime: v.number(), + nome: v.string(), + nascimento: v.string(), + rg: v.string(), + cpf: v.string(), + endereco: v.string(), + cep: v.string(), + cidade: v.string(), + uf: v.string(), + telefone: v.string(), + email: v.string(), + matricula: v.string(), + admissaoData: v.optional(v.string()), + desligamentoData: v.optional(v.string()), + simboloId: v.id("simbolos"), + simboloTipo: simboloTipo, + }), + v.null() + ), + handler: async (ctx, args) => { + return await ctx.db.get(args.id); + }, +}); + export const create = mutation({ args: { nome: v.string(), matricula: v.string(), simboloId: v.id("simbolos"), + nascimento: v.string(), + rg: v.string(), + cpf: v.string(), + endereco: v.string(), + cep: v.string(), + cidade: v.string(), + uf: v.string(), + telefone: v.string(), + email: v.string(), + admissaoData: v.optional(v.string()), + desligamentoData: v.optional(v.string()), + simboloTipo: simboloTipo, }, + returns: v.id("funcionarios"), handler: async (ctx, args) => { + // Unicidade: CPF + const cpfExists = await ctx.db + .query("funcionarios") + .withIndex("by_cpf", (q) => q.eq("cpf", args.cpf)) + .unique(); + if (cpfExists) { + throw new Error("CPF já cadastrado"); + } + + // Unicidade: Matrícula + const matriculaExists = await ctx.db + .query("funcionarios") + .withIndex("by_matricula", (q) => q.eq("matricula", args.matricula)) + .unique(); + if (matriculaExists) { + throw new Error("Matrícula já cadastrada"); + } + const novoFuncionarioId = await ctx.db.insert("funcionarios", { nome: args.nome, + nascimento: args.nascimento, + rg: args.rg, + cpf: args.cpf, + endereco: args.endereco, + cep: args.cep, + cidade: args.cidade, + uf: args.uf, + telefone: args.telefone, + email: args.email, matricula: args.matricula, + admissaoData: args.admissaoData, + desligamentoData: args.desligamentoData, simboloId: args.simboloId, + simboloTipo: args.simboloTipo, }); - return await ctx.db.get(novoFuncionarioId); + return novoFuncionarioId; + }, +}); + +export const update = mutation({ + args: { + id: v.id("funcionarios"), + nome: v.string(), + matricula: v.string(), + simboloId: v.id("simbolos"), + nascimento: v.string(), + rg: v.string(), + cpf: v.string(), + endereco: v.string(), + cep: v.string(), + cidade: v.string(), + uf: v.string(), + telefone: v.string(), + email: v.string(), + admissaoData: v.optional(v.string()), + desligamentoData: v.optional(v.string()), + simboloTipo: simboloTipo, + }, + returns: v.null(), + handler: async (ctx, args) => { + // Unicidade: CPF (excluindo o próprio registro) + const cpfExists = await ctx.db + .query("funcionarios") + .withIndex("by_cpf", (q) => q.eq("cpf", args.cpf)) + .unique(); + if (cpfExists && cpfExists._id !== args.id) { + throw new Error("CPF já cadastrado"); + } + + // Unicidade: Matrícula (excluindo o próprio registro) + const matriculaExists = await ctx.db + .query("funcionarios") + .withIndex("by_matricula", (q) => q.eq("matricula", args.matricula)) + .unique(); + if (matriculaExists && matriculaExists._id !== args.id) { + throw new Error("Matrícula já cadastrada"); + } + + await ctx.db.patch(args.id, { + nome: args.nome, + nascimento: args.nascimento, + rg: args.rg, + cpf: args.cpf, + endereco: args.endereco, + cep: args.cep, + cidade: args.cidade, + uf: args.uf, + telefone: args.telefone, + email: args.email, + matricula: args.matricula, + admissaoData: args.admissaoData, + desligamentoData: args.desligamentoData, + simboloId: args.simboloId, + simboloTipo: args.simboloTipo, + }); + return null; }, }); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 7bd5455..62bd2f0 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -1,6 +1,7 @@ import { defineSchema, defineTable } from "convex/server"; import { Infer, v } from "convex/values"; import { tables } from "./betterAuth/schema"; +import { cidrv4 } from "better-auth"; export const simboloTipo = v.union( v.literal("cargo_comissionado"), @@ -16,24 +17,41 @@ export default defineSchema({ }), funcionarios: defineTable({ nome: v.string(), - nascimento: v.optional(v.string()), - rg: v.optional(v.string()), - cpf: v.optional(v.string()), - endereco: v.optional(v.string()), - cep: v.optional(v.string()), - cidade: v.optional(v.string()), - uf: v.optional(v.string()), - telefone: v.optional(v.string()), - email: v.optional(v.string()), + nascimento: v.string(), + rg: v.string(), + cpf: v.string(), + endereco: v.string(), + cep: v.string(), + cidade: v.string(), + uf: v.string(), + telefone: v.string(), + email: v.string(), matricula: v.string(), - vencimento: v.optional(v.string()), - admissao: v.optional(v.string()), - desligamento: v.optional(v.string()), - ferias: v.optional(v.string()), + admissaoData: v.optional(v.string()), + desligamentoData: v.optional(v.string()), simboloId: v.id("simbolos"), + simboloTipo: simboloTipo, }) .index("by_matricula", ["matricula"]) - .index("by_nome", ["nome"]), + .index("by_nome", ["nome"]) + .index("by_simboloId", ["simboloId"]) + .index("by_simboloTipo", ["simboloTipo"]) + .index("by_cpf", ["cpf"]) + .index("by_rg", ["rg"]), + + atestados: defineTable({ + funcionarioId: v.id("funcionarios"), + dataInicio: v.string(), + dataFim: v.string(), + cid: v.string(), + descricao: v.string(), + }), + + ferias: defineTable({ + funcionarioId: v.id("funcionarios"), + dataInicio: v.string(), + dataFim: v.string(), + }), simbolos: defineTable({ nome: v.string(),