feat: add chart for displaying the last 10 registered products in 'Almoxarifado', enhancing inventory visibility and user engagement

This commit is contained in:
2025-12-21 08:34:55 -03:00
parent f0884a19a7
commit fdbecff4fa
3 changed files with 153 additions and 5 deletions

View File

@@ -10,11 +10,15 @@
BarChart3,
CheckCircle2
} from 'lucide-svelte';
import BarChart3D from '$lib/components/ti/charts/BarChart3D.svelte';
const client = useConvexClient();
const statsQuery = useQuery(api.almoxarifado.obterEstatisticas, {});
const alertasQuery = useQuery(api.almoxarifado.listarAlertas, { status: 'ativo' });
const materiaisQuery = useQuery(api.almoxarifado.listarMateriais, {});
const ultimosProdutosQuery = useQuery(api.almoxarifado.obterUltimosProdutosCadastrados, {
limit: 10
});
// Criar mapa de materiais para lookup eficiente
const materiaisMap = $derived.by(() => {
@@ -25,6 +29,51 @@
}
return map;
});
// Dados formatados para o gráfico
const chartData = $derived.by(() => {
if (!ultimosProdutosQuery?.data || ultimosProdutosQuery.data.length === 0) {
return {
labels: [],
datasets: []
};
}
const produtos = ultimosProdutosQuery.data;
// Ordenar do mais antigo para o mais recente (para o gráfico)
const produtosOrdenados = [...produtos].reverse();
// Criar cores gradientes para cada barra (efeito 3D)
const cores = [
'#6366f1', // indigo
'#8b5cf6', // violet
'#a855f7', // purple
'#c084fc', // fuchsia
'#d946ef', // pink
'#ec4899', // rose
'#f43f5e', // red
'#fb7185', // pink-400
'#f87171', // red-400
'#fb923c' // orange-400
];
return {
labels: produtosOrdenados.map((p) => {
// Truncar nome se muito longo
return p.nome.length > 20 ? p.nome.substring(0, 20) + '...' : p.nome;
}),
datasets: [
{
label: 'Quantidade em Estoque',
data: produtosOrdenados.map((p) => p.estoqueAtual),
backgroundColor: produtosOrdenados.map((_, index) => cores[index % cores.length]),
borderColor: produtosOrdenados.map((_, index) => cores[index % cores.length]),
borderWidth: 2
}
]
};
});
</script>
<main class="container mx-auto px-4 py-6">
@@ -128,6 +177,37 @@
</div>
{/if}
<!-- Gráfico de Produtos x Quantidades -->
<div class="mb-8">
<div class="card bg-base-100 border border-base-300 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">
<div class="rounded-lg bg-primary/20 p-2">
<BarChart3 class="h-6 w-6 text-primary" />
</div>
<span>Últimos 10 Produtos Cadastrados</span>
</h2>
{#if ultimosProdutosQuery === undefined}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if ultimosProdutosQuery.data && ultimosProdutosQuery.data.length > 0}
<div class="w-full">
<BarChart3D data={chartData} height={400} />
</div>
<div class="mt-4 text-sm text-base-content/60">
<p>Mostrando os últimos 10 produtos cadastrados ordenados por data de criação</p>
</div>
{:else}
<div class="alert alert-info shadow-lg">
<Package class="h-6 w-6" />
<span class="font-medium">Nenhum produto cadastrado ainda</span>
</div>
{/if}
</div>
</div>
</div>
<!-- Alertas Recentes -->
<div class="mb-8">
<div class="card bg-base-100 border border-base-300 shadow-xl">

View File

@@ -5,6 +5,7 @@
import { useConvexClient, useQuery } from 'convex-svelte';
import { resolve } from '$app/paths';
import { AlertTriangle, CheckCircle, XCircle, Package } from 'lucide-svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
const client = useConvexClient();
@@ -28,6 +29,8 @@
});
let notice = $state<{ kind: 'success' | 'error'; text: string } | null>(null);
let showConfirmIgnorar = $state(false);
let alertaParaIgnorar = $state<Id<'alertasEstoque'> | null>(null);
function mostrarMensagem(kind: 'success' | 'error', text: string) {
notice = { kind, text };
@@ -36,6 +39,16 @@
}, 5000);
}
function abrirModalIgnorar(id: Id<'alertasEstoque'>) {
alertaParaIgnorar = id;
showConfirmIgnorar = true;
}
function fecharModalIgnorar() {
showConfirmIgnorar = false;
alertaParaIgnorar = null;
}
async function resolverAlerta(id: Id<'alertasEstoque'>) {
try {
await client.mutation(api.almoxarifado.resolverAlerta, { id });
@@ -47,10 +60,8 @@
}
async function ignorarAlerta(id: Id<'alertasEstoque'>) {
if (!confirm('Tem certeza que deseja ignorar este alerta?')) {
return;
}
fecharModalIgnorar();
try {
await client.mutation(api.almoxarifado.ignorarAlerta, { id });
mostrarMensagem('success', 'Alerta ignorado');
@@ -221,7 +232,7 @@
</button>
<button
class="btn btn-sm btn-ghost hover:btn-error"
onclick={() => ignorarAlerta(alerta._id)}
onclick={() => abrirModalIgnorar(alerta._id)}
>
<XCircle class="h-4 w-4" />
Ignorar
@@ -263,6 +274,17 @@
{/if}
</div>
</div>
<!-- Modal de Confirmação para Ignorar Alerta -->
<ConfirmModal
open={showConfirmIgnorar}
title="Ignorar Alerta"
message="Tem certeza que deseja ignorar este alerta? O estoque ainda estará abaixo do mínimo configurado."
confirmText="Sim, ignorar"
cancelText="Cancelar"
onConfirm={() => alertaParaIgnorar && ignorarAlerta(alertaParaIgnorar)}
onCancel={fecharModalIgnorar}
/>
</main>

View File

@@ -351,6 +351,42 @@ export const verificarEstoqueBaixo = query({
}
});
export const obterUltimosProdutosCadastrados = query({
args: {
limit: v.optional(v.number())
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) return [];
try {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'almoxarifado',
acao: 'listar'
});
} catch {
return [];
}
const limit = args.limit ?? 10;
const materiais = await ctx.db.query('materiais').collect();
// Ordenar por data de criação (mais recente primeiro) e pegar os últimos N
const materiaisOrdenados = materiais
.sort((a, b) => b.criadoEm - a.criadoEm)
.slice(0, limit);
return materiaisOrdenados.map((m) => ({
_id: m._id,
nome: m.nome,
codigo: m.codigo,
estoqueAtual: m.estoqueAtual,
unidadeMedida: m.unidadeMedida,
criadoEm: m.criadoEm
}));
}
});
// ========== MUTATIONS ==========
async function registrarHistorico(
@@ -678,6 +714,7 @@ export const registrarEntrada = mutation({
materialId: v.id('materiais'),
quantidade: v.number(),
motivo: v.string(),
funcionarioId: v.optional(v.id('funcionarios')),
documento: v.optional(v.string()),
observacoes: v.optional(v.string())
},
@@ -698,6 +735,9 @@ export const registrarEntrada = mutation({
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) throw new Error('Usuário não autenticado');
// Se funcionarioId não foi fornecido, usar o do usuário logado (se existir)
const funcionarioId = args.funcionarioId || usuario.funcionarioId;
const quantidadeAnterior = material.estoqueAtual;
const quantidadeNova = quantidadeAnterior + args.quantidade;
@@ -716,6 +756,7 @@ export const registrarEntrada = mutation({
quantidadeNova,
motivo: args.motivo,
documento: args.documento,
funcionarioId,
usuarioId: usuario._id,
data: Date.now(),
observacoes: args.observacoes
@@ -818,6 +859,7 @@ export const ajustarEstoque = mutation({
materialId: v.id('materiais'),
quantidadeNova: v.number(),
motivo: v.string(),
funcionarioId: v.optional(v.id('funcionarios')),
observacoes: v.optional(v.string())
},
handler: async (ctx, args) => {
@@ -837,6 +879,9 @@ export const ajustarEstoque = mutation({
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) throw new Error('Usuário não autenticado');
// Se funcionarioId não foi fornecido, usar o do usuário logado (se existir)
const funcionarioId = args.funcionarioId || usuario.funcionarioId;
const quantidadeAnterior = material.estoqueAtual;
const diferenca = args.quantidadeNova - quantidadeAnterior;
@@ -854,6 +899,7 @@ export const ajustarEstoque = mutation({
quantidadeAnterior,
quantidadeNova: args.quantidadeNova,
motivo: args.motivo,
funcionarioId,
usuarioId: usuario._id,
data: Date.now(),
observacoes: args.observacoes