refactor: enhance Sidebar layout with fixed header and footer, improve dashboard layout, and implement symbol management features including deletion and data display

This commit is contained in:
2025-10-24 15:17:49 -03:00
parent 441cda4246
commit d8996b8d5a
6 changed files with 1549 additions and 49 deletions

View File

@@ -1,21 +1,247 @@
<script lang="ts">
import { useQuery } from "convex-svelte";
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
const client = useConvexClient();
const simbolosQuery = useQuery(api.simbolos.getAll, {});
let deletingId: Id<"simbolos"> | null = null;
let simboloToDelete: { id: Id<"simbolos">; nome: string } | null = null;
function openDeleteModal(id: Id<"simbolos">, nome: string) {
simboloToDelete = { id, nome };
(document.getElementById("delete_modal") as HTMLDialogElement)?.showModal();
}
function closeDeleteModal() {
simboloToDelete = null;
(document.getElementById("delete_modal") as HTMLDialogElement)?.close();
}
async function confirmDelete() {
if (!simboloToDelete) return;
try {
deletingId = simboloToDelete.id;
await client.mutation(api.simbolos.remove, { id: simboloToDelete.id });
closeDeleteModal();
} catch (error) {
console.error("Erro ao excluir símbolo:", error);
alert("Erro ao excluir símbolo. Tente novamente.");
} finally {
deletingId = null;
}
}
function formatMoney(value: string) {
const num = parseFloat(value);
if (isNaN(num)) return "R$ 0,00";
return `R$ ${num.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function getTipoLabel(tipo: string) {
return tipo === "cargo_comissionado" ? "Cargo Comissionado" : "Função Gratificada";
}
</script>
<div class="space-y-4">
<h2 class="text-3xl font-bold text-brand-dark">Simbolos</h2>
<div class="space-y-6 pb-32">
<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">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
clip-rule="evenodd"
/>
</svg>
Novo Símbolo
</a>
</div>
{#each simbolosQuery.data as simbolo}
<div class="p-4 rounded-xl border hover:shadow bgbase-100">
<h3 class="text-lg font-bold text-brand-dark">{simbolo.nome}</h3>
<p class="text-sm text-gray-500">{simbolo.vencValor}</p>
<p class="text-sm text-gray-500">{simbolo.repValor}</p>
<p class="text-sm text-gray-500">{simbolo.descricao}</p>
{#if simbolosQuery.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">
<table class="table table-zebra">
<thead>
<tr>
<th>Nome</th>
<th>Tipo</th>
<th>Valor Referência</th>
<th>Valor Vencimento</th>
<th>Valor Total</th>
<th>Descrição</th>
<th class="text-right">Ações</th>
</tr>
</thead>
<tbody>
{#each simbolosQuery.data as simbolo}
<tr class="hover">
<td class="font-medium">{simbolo.nome}</td>
<td>
<span
class="badge"
class:badge-primary={simbolo.tipo === "cargo_comissionado"}
class:badge-secondary={simbolo.tipo === "funcao_gratificada"}
>
{getTipoLabel(simbolo.tipo)}
</span>
</td>
<td>{simbolo.repValor ? formatMoney(simbolo.repValor) : "—"}</td>
<td>{simbolo.vencValor ? formatMoney(simbolo.vencValor) : "—"}</td>
<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">
<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>
</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"
>
<li>
<a href="/recursos-humanos/simbolos/{simbolo._id}/editar">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"
/>
</svg>
Editar
</a>
</li>
<li>
<button
on:click={() => openDeleteModal(simbolo._id, simbolo.nome)}
class="text-error"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
Excluir
</button>
</li>
</ul>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p>Nenhum simbolo encontrado</p>
{/each}
<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>
<!-- Modal de Confirmação de Exclusão -->
<dialog id="delete_modal" 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 simboloToDelete}
<p class="py-2">
Tem certeza que deseja excluir o símbolo <strong class="text-error"
>{simboloToDelete.nome}</strong
>?
</p>
{/if}
<div class="modal-action">
<form method="dialog" class="flex gap-2">
<button class="btn btn-ghost" on:click={closeDeleteModal} type="button">
Cancelar
</button>
<button
class="btn btn-error"
on:click={confirmDelete}
disabled={deletingId !== null}
type="button"
>
{#if deletingId}
<span class="loading loading-spinner loading-sm"></span>
Excluindo...
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
Confirmar Exclusão
{/if}
</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>

View File

@@ -0,0 +1,431 @@
<script lang="ts">
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { createForm } from "@tanstack/svelte-form";
import z from "zod";
import { Save } from "lucide-svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import type { SimboloTipo } from "@sgse-app/backend/convex/schema";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
const client = useConvexClient();
const simboloId = $page.params.simboloId as Id<"simbolos">;
const simboloQuery = useQuery(api.simbolos.getById, { id: simboloId });
let tipo = $state<SimboloTipo>("cargo_comissionado");
let isFormInitialized = $state(false);
const schema = z.object({
nome: z.string().min(1),
refValor: z.string().optional(),
vencValor: z.string().optional(),
descricao: z.string().min(1),
tipo: z.enum(["cargo_comissionado", "funcao_gratificada"]),
valor: z.string().optional(),
});
function formatCurrencyBR(raw: string): string {
const digits = (raw || "").replace(/\D/g, "");
if (!digits) return "";
const number = parseInt(digits, 10);
const cents = (number / 100).toFixed(2);
const [intPart, decPart] = cents.split(".");
const intWithThousands = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ".");
return `${intWithThousands},${decPart}`;
}
function unmaskCurrencyToDotDecimal(masked: string): string {
if (!masked) return "";
const digits = masked.replace(/\D/g, "");
if (!digits) return "";
const value = (parseInt(digits, 10) / 100).toFixed(2);
return value;
}
function formatDotDecimalToBR(value: string): string {
if (!value) return "";
const [intPart, decRaw] = value.split(".");
const intDigits = (intPart || "0").replace(/\D/g, "");
const intWithThousands = intDigits.replace(/\B(?=(\d{3})+(?!\d))/g, ".");
const decPart = (decRaw ?? "00").padEnd(2, "0").slice(0, 2);
return `${intWithThousands},${decPart}`;
}
let notice = $state<{ kind: "success" | "error"; text: string } | null>(null);
function getTotalPreview(): string {
if (tipo !== "cargo_comissionado") return "";
const r = unmaskCurrencyToDotDecimal(form.getFieldValue("refValor"));
const v = unmaskCurrencyToDotDecimal(form.getFieldValue("vencValor"));
if (!r || !v) return "";
const sum = (Number(r) + Number(v)).toFixed(2);
return formatDotDecimalToBR(sum);
}
const form = createForm(() => ({
onSubmit: async ({ value }) => {
const isCargo = value.tipo === "cargo_comissionado";
const payload = {
id: simboloId,
nome: value.nome,
refValor: isCargo ? unmaskCurrencyToDotDecimal(value.refValor) : "",
vencValor: isCargo ? unmaskCurrencyToDotDecimal(value.vencValor) : "",
descricao: value.descricao,
tipo: value.tipo as SimboloTipo,
valor: !isCargo ? unmaskCurrencyToDotDecimal(value.valor) : undefined,
};
try {
await client.mutation(api.simbolos.update, payload);
notice = { kind: "success", text: "Símbolo atualizado com sucesso." };
setTimeout(() => goto("/recursos-humanos/simbolos"), 600);
} catch (error) {
console.error("erro ao atualizar símbolo", error);
notice = { kind: "error", text: "Erro ao atualizar símbolo." };
}
},
defaultValues: {
nome: "",
refValor: "",
vencValor: "",
descricao: "",
tipo: "cargo_comissionado",
valor: "",
},
}));
// Inicializar o formulário quando os dados estiverem disponíveis
$effect(() => {
if (simboloQuery.data && !isFormInitialized) {
const simbolo = simboloQuery.data;
tipo = simbolo.tipo;
form.setFieldValue("nome", simbolo.nome);
form.setFieldValue("descricao", simbolo.descricao);
form.setFieldValue("tipo", simbolo.tipo);
if (simbolo.tipo === "cargo_comissionado") {
form.setFieldValue("refValor", formatDotDecimalToBR(simbolo.repValor));
form.setFieldValue("vencValor", formatDotDecimalToBR(simbolo.vencValor));
} else {
form.setFieldValue("valor", formatDotDecimalToBR(simbolo.valor));
}
isFormInitialized = true;
}
});
</script>
{#if simboloQuery.isLoading}
<div class="flex justify-center items-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if !simboloQuery.data}
<div class="alert alert-error max-w-3xl mx-auto m-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Símbolo não encontrado.</span>
</div>
{:else}
<form
class="max-w-3xl mx-auto p-4"
onsubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<div class="card bg-base-100 shadow-xl">
<div class="card-body space-y-6">
{#if notice}
<div
class="alert"
class:alert-success={notice.kind === "success"}
class:alert-error={notice.kind === "error"}
>
<span>{notice.text}</span>
</div>
{/if}
<div>
<h2 class="card-title text-3xl">Editar Símbolo</h2>
<p class="opacity-70">
Atualize os campos abaixo para editar o símbolo.
</p>
</div>
<form.Field name="nome" validators={{ onChange: schema.shape.nome }}>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for="nome">
<span class="label-text font-medium"
>Símbolo <span class="text-error">*</span></span
>
</label>
<input
{name}
value={state.value}
placeholder="Ex.: DAS-1"
class="input input-bordered w-full"
autocomplete="off"
oninput={(e) => {
const target = e.target as HTMLInputElement;
const value = target.value;
handleChange(value);
}}
required
aria-required="true"
/>
<div class="label">
<span class="label-text-alt opacity-60"
>Informe o nome identificador do símbolo.</span
>
</div>
</div>
{/snippet}
</form.Field>
<form.Field
name="descricao"
validators={{ onChange: schema.shape.descricao }}
>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for="descricao">
<span class="label-text font-medium"
>Descrição <span class="text-error">*</span></span
>
</label>
<input
{name}
value={state.value}
placeholder="Ex.: Cargo de Apoio 1"
class="input input-bordered w-full"
autocomplete="off"
oninput={(e) => {
const target = e.target as HTMLInputElement;
const value = target.value;
handleChange(value);
}}
required
aria-required="true"
/>
<div class="label">
<span class="label-text-alt opacity-60"
>Descreva brevemente o símbolo.</span
>
</div>
</div>
{/snippet}
</form.Field>
<form.Field
name="tipo"
validators={{
onChange: ({ value }) => (value ? undefined : "Obrigatório"),
}}
>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for="tipo">
<span class="label-text font-medium"
>Tipo <span class="text-error">*</span></span
>
</label>
<select
{name}
class="select select-bordered w-full"
bind:value={tipo}
oninput={(e) => {
const target = e.target as HTMLSelectElement;
const value = target.value;
handleChange(value);
}}
required
aria-required="true"
>
<option value="cargo_comissionado">Cargo comissionado</option>
<option value="funcao_gratificada">Função gratificada</option>
</select>
</div>
{/snippet}
</form.Field>
{#if tipo === "cargo_comissionado"}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<form.Field
name="vencValor"
validators={{
onChange: ({ value }) =>
form.getFieldValue("tipo") === "cargo_comissionado" && !value
? "Obrigatório"
: undefined,
}}
>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for="vencValor">
<span class="label-text font-medium"
>Valor de Vencimento <span class="text-error">*</span></span
>
</label>
<input
{name}
value={state.value}
placeholder="Ex.: 1200,00"
class="input input-bordered w-full"
inputmode="decimal"
autocomplete="off"
oninput={(e) => {
const target = e.target as HTMLInputElement;
const formatted = formatCurrencyBR(target.value);
target.value = formatted;
handleChange(formatted);
}}
required
aria-required="true"
/>
<div class="label">
<span class="label-text-alt opacity-60"
>Valor efetivo de vencimento.</span
>
</div>
</div>
{/snippet}
</form.Field>
<form.Field
name="refValor"
validators={{
onChange: ({ value }) =>
form.getFieldValue("tipo") === "cargo_comissionado" && !value
? "Obrigatório"
: undefined,
}}
>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for="refValor">
<span class="label-text font-medium"
>Valor de Referência <span class="text-error">*</span></span
>
</label>
<input
{name}
value={state.value}
placeholder="Ex.: 1000,00"
class="input input-bordered w-full"
inputmode="decimal"
autocomplete="off"
oninput={(e) => {
const target = e.target as HTMLInputElement;
const formatted = formatCurrencyBR(target.value);
target.value = formatted;
handleChange(formatted);
}}
required
aria-required="true"
/>
<div class="label">
<span class="label-text-alt opacity-60"
>Valor base de referência.</span
>
</div>
</div>
{/snippet}
</form.Field>
</div>
{#if getTotalPreview()}
<div class="alert bg-base-200">
<span>Total previsto: R$ {getTotalPreview()}</span>
</div>
{/if}
{:else}
<form.Field
name="valor"
validators={{
onChange: ({ value }) =>
form.getFieldValue("tipo") === "funcao_gratificada" && !value
? "Obrigatório"
: undefined,
}}
>
{#snippet children({ name, state, handleChange })}
<div class="form-control">
<label class="label" for="valor">
<span class="label-text font-medium"
>Valor <span class="text-error">*</span></span
>
</label>
<input
{name}
value={state.value}
placeholder="Ex.: 1.500,00"
class="input input-bordered w-full"
inputmode="decimal"
autocomplete="off"
oninput={(e) => {
const target = e.target as HTMLInputElement;
const formatted = formatCurrencyBR(target.value);
target.value = formatted;
handleChange(formatted);
}}
required
aria-required="true"
/>
<div class="label">
<span class="label-text-alt opacity-60"
>Informe o valor da função gratificada.</span
>
</div>
</div>
{/snippet}
</form.Field>
{/if}
<form.Subscribe
selector={(state) => ({
canSubmit: state.canSubmit,
isSubmitting: state.isSubmitting,
})}
>
{#snippet children({ canSubmit, isSubmitting })}
<div class="card-actions justify-end pt-2">
<button
type="button"
class="btn btn-ghost"
disabled={isSubmitting}
onclick={() => goto("/recursos-humanos/simbolos")}
>
Cancelar
</button>
<button
type="submit"
class="btn btn-primary"
class:loading={isSubmitting}
disabled={isSubmitting || !canSubmit}
>
<Save class="h-5 w-5" />
<span>Salvar Alterações</span>
</button>
</div>
{/snippet}
</form.Subscribe>
</div>
</div>
</form>
{/if}