feat: implement almoxarifado features including new category in recursos-humanos, configuration options in TI, and backend support for inventory management, enhancing user navigation and system functionality

This commit is contained in:
2025-12-18 16:21:08 -03:00
parent 1eb454815f
commit 367cda7b95
22 changed files with 4831 additions and 2 deletions

View File

@@ -19,7 +19,10 @@
ArrowRight,
Clock,
XCircle,
TrendingUp
TrendingUp,
Package,
ArrowLeftRight,
AlertTriangle
} from 'lucide-svelte';
import type { Component } from 'svelte';
@@ -155,6 +158,58 @@
Icon: List
}
]
},
{
categoria: 'Almoxarifado',
descricao: 'Controle de estoque e gestão de materiais',
Icon: Package,
gradient: 'from-amber-500/10 to-amber-600/20',
accentColor: 'text-amber-600',
bgIcon: 'bg-amber-500/20',
opcoes: [
{
nome: 'Dashboard',
descricao: 'Visão geral do almoxarifado',
href: '/recursos-humanos/almoxarifado',
Icon: BarChart3
},
{
nome: 'Cadastrar Material',
descricao: 'Adicionar novo material ao estoque',
href: '/recursos-humanos/almoxarifado/materiais/cadastro',
Icon: Plus
},
{
nome: 'Listar Materiais',
descricao: 'Visualizar e gerenciar materiais',
href: '/recursos-humanos/almoxarifado/materiais',
Icon: Package
},
{
nome: 'Movimentações',
descricao: 'Registrar entradas e saídas',
href: '/recursos-humanos/almoxarifado/movimentacoes',
Icon: ArrowLeftRight
},
{
nome: 'Requisições',
descricao: 'Gerenciar requisições de material',
href: '/recursos-humanos/almoxarifado/requisicoes',
Icon: ClipboardList
},
{
nome: 'Alertas',
descricao: 'Visualizar alertas de estoque baixo',
href: '/recursos-humanos/almoxarifado/alertas',
Icon: AlertTriangle
},
{
nome: 'Relatórios',
descricao: 'Relatórios e estatísticas',
href: '/recursos-humanos/almoxarifado/relatorios',
Icon: BarChart3
}
]
}
];
</script>

View File

@@ -0,0 +1,196 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useConvexClient, useQuery } from 'convex-svelte';
import { goto } from '$app/navigation';
import {
Package,
AlertTriangle,
ArrowLeftRight,
BarChart3,
CheckCircle2
} from 'lucide-svelte';
const client = useConvexClient();
const statsQuery = useQuery(api.almoxarifado.obterEstatisticas, {});
const alertasQuery = useQuery(api.almoxarifado.listarAlertas, { status: 'ativo' });
const materiaisQuery = useQuery(api.almoxarifado.listarMateriais, {});
</script>
<main class="container mx-auto px-4 py-4">
<!-- Cabeçalho -->
<div class="mb-8">
<h1 class="text-primary mb-2 text-4xl font-bold">Almoxarifado</h1>
<p class="text-base-content/70 text-lg">
Controle de estoque e gestão de materiais
</p>
</div>
<!-- Estatísticas -->
{#if statsQuery.data}
<div class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-4">
<div class="stats from-primary/10 to-primary/20 bg-linear-to-br shadow-lg">
<div class="stat">
<div class="stat-figure text-primary">
<Package class="h-8 w-8" strokeWidth={2} />
</div>
<div class="stat-title">Total de Materiais</div>
<div class="stat-value text-primary">
{statsQuery.data.totalMateriais}
</div>
<div class="stat-desc">Materiais cadastrados</div>
</div>
</div>
<div class="stats from-success/10 to-success/20 bg-linear-to-br shadow-lg">
<div class="stat">
<div class="stat-figure text-success">
<CheckCircle2 class="h-8 w-8" strokeWidth={2} />
</div>
<div class="stat-title">Materiais Ativos</div>
<div class="stat-value text-success">
{statsQuery.data.totalMateriaisAtivos}
</div>
<div class="stat-desc">Em estoque</div>
</div>
</div>
<div class="stats from-warning/10 to-warning/20 bg-linear-to-br shadow-lg">
<div class="stat">
<div class="stat-figure text-warning">
<AlertTriangle class="h-8 w-8" strokeWidth={2} />
</div>
<div class="stat-title">Alertas Ativos</div>
<div class="stat-value text-warning">
{statsQuery.data.totalAlertasAtivos}
</div>
<div class="stat-desc">Estoque baixo</div>
</div>
</div>
<div class="stats from-info/10 to-info/20 bg-linear-to-br shadow-lg">
<div class="stat">
<div class="stat-figure text-info">
<ArrowLeftRight class="h-8 w-8" strokeWidth={2} />
</div>
<div class="stat-title">Movimentações</div>
<div class="stat-value text-info">
{statsQuery.data.movimentacoesMes}
</div>
<div class="stat-desc">Este mês</div>
</div>
</div>
</div>
{/if}
<!-- Alertas Recentes -->
<div class="mb-8">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl">
<AlertTriangle class="h-6 w-6 text-warning" />
Alertas de Estoque
</h2>
{#if alertasQuery.data && alertasQuery.data.length > 0}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Material</th>
<th>Tipo</th>
<th>Quantidade Atual</th>
<th>Quantidade Mínima</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each alertasQuery.data.slice(0, 5) as alerta}
{@const material = materiaisQuery.data?.find(m => m._id === alerta.materialId)}
<tr>
<td>
{material?.nome || 'Carregando...'}
</td>
<td>
{#if alerta.tipo === 'estoque_zerado'}
<span class="badge badge-error">Zerado</span>
{:else if alerta.tipo === 'estoque_minimo'}
<span class="badge badge-warning">Mínimo</span>
{:else}
<span class="badge badge-info">Reposição</span>
{/if}
</td>
<td>{alerta.quantidadeAtual}</td>
<td>{alerta.quantidadeMinima}</td>
<td>
<button
class="btn btn-sm btn-primary"
onclick={() => goto('/recursos-humanos/almoxarifado/alertas')}
>
Ver Detalhes
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="card-actions justify-end">
<button
class="btn btn-primary"
onclick={() => goto('/recursos-humanos/almoxarifado/alertas')}
>
Ver Todos os Alertas
</button>
</div>
{:else}
<div class="alert alert-success">
<CheckCircle2 class="h-6 w-6" />
<span>Nenhum alerta ativo no momento!</span>
</div>
{/if}
</div>
</div>
</div>
<!-- Ações Rápidas -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<button
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow"
onclick={() => goto('/recursos-humanos/almoxarifado/materiais/cadastro')}
>
<div class="card-body">
<h3 class="card-title">
<Package class="h-6 w-6 text-primary" />
Cadastrar Material
</h3>
<p class="text-base-content/70">Adicionar novo material ao estoque</p>
</div>
</button>
<button
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow"
onclick={() => goto('/recursos-humanos/almoxarifado/movimentacoes')}
>
<div class="card-body">
<h3 class="card-title">
<ArrowLeftRight class="h-6 w-6 text-info" />
Registrar Movimentação
</h3>
<p class="text-base-content/70">Registrar entrada ou saída de material</p>
</div>
</button>
<button
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow"
onclick={() => goto('/recursos-humanos/almoxarifado/relatorios')}
>
<div class="card-body">
<h3 class="card-title">
<BarChart3 class="h-6 w-6 text-success" />
Relatórios
</h3>
<p class="text-base-content/70">Visualizar relatórios e estatísticas</p>
</div>
</button>
</div>
</main>

View File

@@ -0,0 +1,242 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import type { AlertaStatus, AlertaTipo } from '@sgse-app/backend/convex/tables/almoxarifado';
import { useConvexClient, useQuery } from 'convex-svelte';
import { resolve } from '$app/paths';
import { AlertTriangle, CheckCircle, XCircle, Package } from 'lucide-svelte';
const client = useConvexClient();
let filtroTipo = $state<string>('');
let filtroStatus = $state<string>('ativo');
const alertasQuery = useQuery(api.almoxarifado.listarAlertas, {
status: filtroStatus ? (filtroStatus as AlertaStatus) : undefined,
tipo: filtroTipo ? (filtroTipo as AlertaTipo) : undefined
});
const materiaisQuery = useQuery(api.almoxarifado.listarMateriais, {});
let notice = $state<{ kind: 'success' | 'error'; text: string } | null>(null);
function mostrarMensagem(kind: 'success' | 'error', text: string) {
notice = { kind, text };
setTimeout(() => {
notice = null;
}, 5000);
}
async function resolverAlerta(id: Id<'alertasEstoque'>) {
try {
await client.mutation(api.almoxarifado.resolverAlerta, { id });
mostrarMensagem('success', 'Alerta resolvido com sucesso!');
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao resolver alerta';
mostrarMensagem('error', message);
}
}
async function ignorarAlerta(id: Id<'alertasEstoque'>) {
if (!confirm('Tem certeza que deseja ignorar este alerta?')) {
return;
}
try {
await client.mutation(api.almoxarifado.ignorarAlerta, { id });
mostrarMensagem('success', 'Alerta ignorado');
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao ignorar alerta';
mostrarMensagem('error', message);
}
}
function getTipoBadge(tipo: string) {
switch (tipo) {
case 'estoque_zerado':
return 'badge-error';
case 'estoque_minimo':
return 'badge-warning';
case 'reposicao_necessaria':
return 'badge-info';
default:
return 'badge-ghost';
}
}
function getTipoLabel(tipo: string) {
switch (tipo) {
case 'estoque_zerado':
return 'Estoque Zerado';
case 'estoque_minimo':
return 'Estoque Mínimo';
case 'reposicao_necessaria':
return 'Reposição Necessária';
default:
return tipo;
}
}
</script>
<main class="container mx-auto px-4 py-4">
<!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm">
<ul>
<li>
<a href={resolve('/recursos-humanos')} class="text-primary hover:underline"
>Recursos Humanos</a
>
</li>
<li>
<a href={resolve('/recursos-humanos/almoxarifado')} class="text-primary hover:underline"
>Almoxarifado</a
>
</li>
<li>Alertas</li>
</ul>
</div>
<!-- Cabeçalho -->
<div class="mb-6">
<div class="flex items-center gap-4">
<div class="rounded-xl bg-warning/20 p-3">
<AlertTriangle class="h-8 w-8 text-warning" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold">Alertas de Estoque</h1>
<p class="text-base-content/70">Visualize e gerencie alertas de estoque baixo</p>
</div>
</div>
</div>
<!-- Notificações -->
{#if notice}
<div class="alert alert-{notice.kind} mb-6 shadow-lg">
<span>{notice.text}</span>
</div>
{/if}
<!-- Filtros -->
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Tipo de Alerta</span>
</label>
<select class="select select-bordered" bind:value={filtroTipo}>
<option value="">Todos</option>
<option value="estoque_zerado">Estoque Zerado</option>
<option value="estoque_minimo">Estoque Mínimo</option>
<option value="reposicao_necessaria">Reposição Necessária</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Status</span>
</label>
<select class="select select-bordered" bind:value={filtroStatus}>
<option value="">Todos</option>
<option value="ativo">Ativo</option>
<option value="resolvido">Resolvido</option>
<option value="ignorado">Ignorado</option>
</select>
</div>
</div>
</div>
</div>
<!-- Lista de Alertas -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
{#if alertasQuery.data && alertasQuery.data.length > 0}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Material</th>
<th>Tipo</th>
<th>Quantidade Atual</th>
<th>Quantidade Mínima</th>
<th>Diferença</th>
<th>Status</th>
<th>Data</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each alertasQuery.data as alerta}
{@const material = materiaisQuery.data?.find(m => m._id === alerta.materialId)}
{@const diferenca = alerta.quantidadeMinima - alerta.quantidadeAtual}
<tr>
<td>
<div class="font-medium">{material?.nome || 'Carregando...'}</div>
<div class="text-sm text-base-content/60">{material?.codigo || ''}</div>
</td>
<td>
<span class="badge {getTipoBadge(alerta.tipo)}">
{getTipoLabel(alerta.tipo)}
</span>
</td>
<td>
<div class="font-bold text-error">{alerta.quantidadeAtual}</div>
</td>
<td>
<div class="font-medium">{alerta.quantidadeMinima}</div>
</td>
<td>
<div class="font-bold text-warning">-{diferenca}</div>
</td>
<td>
{#if alerta.status === 'ativo'}
<span class="badge badge-warning">Ativo</span>
{:else if alerta.status === 'resolvido'}
<span class="badge badge-success">Resolvido</span>
{:else}
<span class="badge badge-ghost">Ignorado</span>
{/if}
</td>
<td>{new Date(alerta.criadoEm).toLocaleDateString('pt-BR')}</td>
<td>
{#if alerta.status === 'ativo'}
<div class="flex gap-2">
<button
class="btn btn-sm btn-success"
onclick={() => resolverAlerta(alerta._id)}
>
<CheckCircle class="h-4 w-4" />
Resolver
</button>
<button
class="btn btn-sm btn-ghost"
onclick={() => ignorarAlerta(alerta._id)}
>
<XCircle class="h-4 w-4" />
Ignorar
</button>
</div>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="text-center py-12">
<CheckCircle class="mx-auto mb-4 h-16 w-16 text-success" />
<h3 class="text-xl font-bold mb-2">Nenhum alerta encontrado</h3>
<p class="text-base-content/70">
{#if filtroStatus === 'ativo'}
Não há alertas ativos no momento. Todos os materiais estão com estoque adequado!
{:else}
Não há alertas com os filtros selecionados.
{/if}
</p>
</div>
{/if}
</div>
</div>
</main>

View File

@@ -0,0 +1,248 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { Package, Plus, Search, Edit, Eye, AlertTriangle } from 'lucide-svelte';
const client = useConvexClient();
let materiais = $state<Array<Doc<'materiais'>>>([]);
let filtered = $state<Array<Doc<'materiais'>>>([]);
let filtroBusca = $state('');
let filtroCategoria = $state('');
let filtroAtivo = $state<boolean | ''>('');
let filtroEstoqueBaixo = $state(false);
const categorias = $derived(
Array.from(new Set(materiais.map((m) => m.categoria).filter(Boolean))).sort()
);
function applyFilters() {
const busca = filtroBusca.toLowerCase();
filtered = materiais.filter((m) => {
const okBusca =
!busca ||
m.codigo.toLowerCase().includes(busca) ||
m.nome.toLowerCase().includes(busca);
const okCategoria = !filtroCategoria || m.categoria === filtroCategoria;
const okAtivo = filtroAtivo === '' || m.ativo === filtroAtivo;
const okEstoqueBaixo = !filtroEstoqueBaixo || m.estoqueAtual <= m.estoqueMinimo;
return okBusca && okCategoria && okAtivo && okEstoqueBaixo;
});
}
async function load() {
const data = await client.query(api.almoxarifado.listarMateriais, {});
materiais = data ?? [];
applyFilters();
}
$effect(() => {
load();
});
$effect(() => {
applyFilters();
});
function navCadastro() {
goto(resolve('/recursos-humanos/almoxarifado/materiais/cadastro'));
}
</script>
<main class="container mx-auto px-4 py-4">
<!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm">
<ul>
<li>
<a href={resolve('/recursos-humanos')} class="text-primary hover:underline"
>Recursos Humanos</a
>
</li>
<li>
<a href={resolve('/recursos-humanos/almoxarifado')} class="text-primary hover:underline"
>Almoxarifado</a
>
</li>
<li>Materiais</li>
</ul>
</div>
<!-- Cabeçalho -->
<div class="mb-6">
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div class="flex items-center gap-4">
<div class="rounded-xl bg-amber-500/20 p-3">
<Package class="h-8 w-8 text-amber-600" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold">Materiais</h1>
<p class="text-base-content/70">Gerencie o cadastro de materiais do almoxarifado</p>
</div>
</div>
<button class="btn btn-primary" onclick={navCadastro}>
<Plus class="h-5 w-5" />
Cadastrar Material
</button>
</div>
</div>
<!-- Filtros -->
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<div class="form-control">
<label class="label">
<span class="label-text">Buscar</span>
</label>
<div class="relative">
<Search class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-base-content/40" />
<input
type="text"
placeholder="Código ou nome..."
class="input input-bordered w-full pl-10"
bind:value={filtroBusca}
/>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Categoria</span>
</label>
<select class="select select-bordered" bind:value={filtroCategoria}>
<option value="">Todas</option>
{#each categorias as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Status</span>
</label>
<select class="select select-bordered" bind:value={filtroAtivo}>
<option value="">Todos</option>
<option value={true}>Ativos</option>
<option value={false}>Inativos</option>
</select>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" class="checkbox" bind:checked={filtroEstoqueBaixo} />
<span class="label-text">Apenas estoque baixo</span>
</label>
</div>
</div>
</div>
</div>
<!-- Tabela -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Código</th>
<th>Nome</th>
<th>Categoria</th>
<th>Estoque Atual</th>
<th>Estoque Mínimo</th>
<th>Unidade</th>
<th>Status</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#if filtered.length === 0}
<tr>
<td colspan="8" class="text-center">
<div class="py-8">
<Package class="mx-auto mb-4 h-12 w-12 text-base-content/30" />
<p class="text-base-content/70">Nenhum material encontrado</p>
</div>
</td>
</tr>
{:else}
{#each filtered as material}
<tr>
<td>
<div class="font-mono font-bold">{material.codigo}</div>
</td>
<td>
<div class="font-medium">{material.nome}</div>
{#if material.descricao}
<div class="text-sm text-base-content/60">{material.descricao}</div>
{/if}
</td>
<td>
<span class="badge badge-outline">{material.categoria}</span>
</td>
<td>
<div class="flex items-center gap-2">
<span class="font-bold">{material.estoqueAtual}</span>
{#if material.estoqueAtual <= material.estoqueMinimo}
<AlertTriangle class="h-4 w-4 text-warning" />
{/if}
</div>
</td>
<td>{material.estoqueMinimo}</td>
<td>{material.unidadeMedida}</td>
<td>
{#if material.ativo}
<span class="badge badge-success">Ativo</span>
{:else}
<span class="badge badge-error">Inativo</span>
{/if}
</td>
<td>
<div class="flex gap-2">
<button
class="btn btn-sm btn-ghost"
onclick={() =>
goto(
resolve(
'/recursos-humanos/almoxarifado/materiais/' +
material._id
)
)}
>
<Eye class="h-4 w-4" />
</button>
<button
class="btn btn-sm btn-ghost"
onclick={() =>
goto(
resolve(
'/recursos-humanos/almoxarifado/materiais/' +
material._id +
'/editar'
)
)}
>
<Edit class="h-4 w-4" />
</button>
</div>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
{#if filtered.length > 0}
<div class="mt-4 text-sm text-base-content/70">
Mostrando {filtered.length} de {materiais.length} materiais
</div>
{/if}
</div>
</div>
</main>

View File

@@ -0,0 +1,319 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useConvexClient } from 'convex-svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { Package, Save, ArrowLeft } from 'lucide-svelte';
const client = useConvexClient();
let codigo = $state('');
let nome = $state('');
let descricao = $state('');
let categoria = $state('');
let unidadeMedida = $state('UN');
let estoqueMinimo = $state(10);
let estoqueMaximo = $state<number | undefined>(undefined);
let estoqueAtual = $state(0);
let localizacao = $state('');
let fornecedor = $state('');
let loading = $state(false);
let notice = $state<{ kind: 'success' | 'error'; text: string } | null>(null);
const unidadesMedida = ['UN', 'CX', 'KG', 'L', 'M', 'M²', 'M³', 'PC', 'DZ'];
const categoriasComuns = [
'Escritório',
'Limpeza',
'TI',
'Manutenção',
'Elétrico',
'Hidráulico',
'Outros'
];
function mostrarMensagem(kind: 'success' | 'error', text: string) {
notice = { kind, text };
setTimeout(() => {
notice = null;
}, 5000);
}
async function handleSubmit() {
// Validação
if (!codigo.trim() || !nome.trim() || !categoria.trim()) {
mostrarMensagem('error', 'Preencha todos os campos obrigatórios');
return;
}
if (estoqueMinimo < 0) {
mostrarMensagem('error', 'Estoque mínimo não pode ser negativo');
return;
}
if (estoqueAtual < 0) {
mostrarMensagem('error', 'Estoque atual não pode ser negativo');
return;
}
if (estoqueMaximo !== undefined && estoqueMaximo < estoqueMinimo) {
mostrarMensagem('error', 'Estoque máximo deve ser maior ou igual ao mínimo');
return;
}
try {
loading = true;
const materialId = await client.mutation(api.almoxarifado.criarMaterial, {
codigo: codigo.trim(),
nome: nome.trim(),
descricao: descricao.trim() || undefined,
categoria: categoria.trim(),
unidadeMedida,
estoqueMinimo,
estoqueMaximo,
estoqueAtual,
localizacao: localizacao.trim() || undefined,
fornecedor: fornecedor.trim() || undefined
});
mostrarMensagem('success', 'Material cadastrado com sucesso!');
setTimeout(() => {
goto(resolve('/recursos-humanos/almoxarifado/materiais'));
}, 1500);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao cadastrar material';
mostrarMensagem('error', message);
} finally {
loading = false;
}
}
</script>
<main class="container mx-auto px-4 py-4">
<!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm">
<ul>
<li>
<a href={resolve('/recursos-humanos')} class="text-primary hover:underline"
>Recursos Humanos</a
>
</li>
<li>
<a href={resolve('/recursos-humanos/almoxarifado')} class="text-primary hover:underline"
>Almoxarifado</a
>
</li>
<li>
<a href={resolve('/recursos-humanos/almoxarifado/materiais')} class="text-primary hover:underline"
>Materiais</a
>
</li>
<li>Cadastrar</li>
</ul>
</div>
<!-- Cabeçalho -->
<div class="mb-6">
<div class="flex items-center gap-4">
<button
class="btn btn-ghost btn-sm"
onclick={() => goto(resolve('/recursos-humanos/almoxarifado/materiais'))}
>
<ArrowLeft class="h-5 w-5" />
</button>
<div class="rounded-xl bg-amber-500/20 p-3">
<Package class="h-8 w-8 text-amber-600" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold">Cadastrar Material</h1>
<p class="text-base-content/70">Adicione um novo material ao almoxarifado</p>
</div>
</div>
</div>
<!-- Notificações -->
{#if notice}
<div class="alert alert-{notice.kind} mb-6 shadow-lg">
<span>{notice.text}</span>
</div>
{/if}
<!-- Formulário -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Código -->
<div class="form-control md:col-span-1">
<label class="label">
<span class="label-text font-bold">Código *</span>
</label>
<input
type="text"
class="input input-bordered"
placeholder="Ex: MAT-001"
bind:value={codigo}
required
/>
<label class="label">
<span class="label-text-alt">Código único do material</span>
</label>
</div>
<!-- Nome -->
<div class="form-control md:col-span-1">
<label class="label">
<span class="label-text font-bold">Nome *</span>
</label>
<input
type="text"
class="input input-bordered"
placeholder="Nome do material"
bind:value={nome}
required
/>
</div>
<!-- Descrição -->
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Descrição</span>
</label>
<textarea
class="textarea textarea-bordered"
placeholder="Descrição detalhada do material (opcional)"
bind:value={descricao}
rows="3"
></textarea>
</div>
<!-- Categoria -->
<div class="form-control md:col-span-1">
<label class="label">
<span class="label-text font-bold">Categoria *</span>
</label>
<input
type="text"
class="input input-bordered"
list="categorias"
placeholder="Ex: Escritório"
bind:value={categoria}
required
/>
<datalist id="categorias">
{#each categoriasComuns as cat}
<option value={cat} />
{/each}
</datalist>
</div>
<!-- Unidade de Medida -->
<div class="form-control md:col-span-1">
<label class="label">
<span class="label-text font-bold">Unidade de Medida *</span>
</label>
<select class="select select-bordered" bind:value={unidadeMedida} required>
{#each unidadesMedida as un}
<option value={un}>{un}</option>
{/each}
</select>
</div>
<!-- Estoque Mínimo -->
<div class="form-control md:col-span-1">
<label class="label">
<span class="label-text font-bold">Estoque Mínimo *</span>
</label>
<input
type="number"
class="input input-bordered"
min="0"
bind:value={estoqueMinimo}
required
/>
</div>
<!-- Estoque Máximo -->
<div class="form-control md:col-span-1">
<label class="label">
<span class="label-text">Estoque Máximo</span>
</label>
<input
type="number"
class="input input-bordered"
min="0"
bind:value={estoqueMaximo}
/>
<label class="label">
<span class="label-text-alt">Opcional</span>
</label>
</div>
<!-- Estoque Atual -->
<div class="form-control md:col-span-1">
<label class="label">
<span class="label-text">Estoque Inicial</span>
</label>
<input
type="number"
class="input input-bordered"
min="0"
bind:value={estoqueAtual}
/>
<label class="label">
<span class="label-text-alt">Quantidade inicial em estoque</span>
</label>
</div>
<!-- Localização -->
<div class="form-control md:col-span-1">
<label class="label">
<span class="label-text">Localização</span>
</label>
<input
type="text"
class="input input-bordered"
placeholder="Ex: Prateleira A-01"
bind:value={localizacao}
/>
</div>
<!-- Fornecedor -->
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Fornecedor</span>
</label>
<input
type="text"
class="input input-bordered"
placeholder="Nome do fornecedor (opcional)"
bind:value={fornecedor}
/>
</div>
</div>
<!-- Botões -->
<div class="card-actions mt-6 justify-end">
<button
type="button"
class="btn btn-ghost"
onclick={() => goto(resolve('/recursos-humanos/almoxarifado/materiais'))}
disabled={loading}
>
Cancelar
</button>
<button type="submit" class="btn btn-primary" disabled={loading}>
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Save class="h-5 w-5" />
{/if}
Cadastrar
</button>
</div>
</form>
</div>
</div>
</main>

View File

@@ -0,0 +1,550 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { resolve } from '$app/paths';
import { ArrowLeftRight, ArrowDown, ArrowUp, Settings, History } from 'lucide-svelte';
const client = useConvexClient();
let abaAtiva = $state<'entrada' | 'saida' | 'ajuste' | 'historico'>('entrada');
// Estados do formulário de entrada
let entradaMaterialId = $state<Id<'materiais'> | ''>('');
let entradaQuantidade = $state(0);
let entradaMotivo = $state('');
let entradaDocumento = $state('');
let entradaObservacoes = $state('');
let entradaLoading = $state(false);
// Estados do formulário de saída
let saidaMaterialId = $state<Id<'materiais'> | ''>('');
let saidaQuantidade = $state(0);
let saidaMotivo = $state('');
let saidaFuncionarioId = $state<Id<'funcionarios'> | ''>('');
let saidaSetorId = $state<Id<'setores'> | ''>('');
let saidaObservacoes = $state('');
let saidaLoading = $state(false);
// Estados do formulário de ajuste
let ajusteMaterialId = $state<Id<'materiais'> | ''>('');
let ajusteQuantidadeNova = $state(0);
let ajusteMotivo = $state('');
let ajusteObservacoes = $state('');
let ajusteLoading = $state(false);
// Queries
const materiaisQuery = useQuery(api.almoxarifado.listarMateriais, { ativo: true });
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const setoresQuery = useQuery(api.setores.list, {});
const movimentacoesQuery = useQuery(api.almoxarifado.listarMovimentacoes, {});
let notice = $state<{ kind: 'success' | 'error'; text: string } | null>(null);
function mostrarMensagem(kind: 'success' | 'error', text: string) {
notice = { kind, text };
setTimeout(() => {
notice = null;
}, 5000);
}
async function registrarEntrada() {
if (!entradaMaterialId || entradaQuantidade <= 0 || !entradaMotivo.trim()) {
mostrarMensagem('error', 'Preencha todos os campos obrigatórios');
return;
}
try {
entradaLoading = true;
await client.mutation(api.almoxarifado.registrarEntrada, {
materialId: entradaMaterialId as Id<'materiais'>,
quantidade: entradaQuantidade,
motivo: entradaMotivo.trim(),
documento: entradaDocumento.trim() || undefined,
observacoes: entradaObservacoes.trim() || undefined
});
mostrarMensagem('success', 'Entrada registrada com sucesso!');
// Limpar formulário
entradaMaterialId = '';
entradaQuantidade = 0;
entradaMotivo = '';
entradaDocumento = '';
entradaObservacoes = '';
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao registrar entrada';
mostrarMensagem('error', message);
} finally {
entradaLoading = false;
}
}
async function registrarSaida() {
if (!saidaMaterialId || saidaQuantidade <= 0 || !saidaMotivo.trim()) {
mostrarMensagem('error', 'Preencha todos os campos obrigatórios');
return;
}
try {
saidaLoading = true;
await client.mutation(api.almoxarifado.registrarSaida, {
materialId: saidaMaterialId as Id<'materiais'>,
quantidade: saidaQuantidade,
motivo: saidaMotivo.trim(),
funcionarioId: saidaFuncionarioId
? (saidaFuncionarioId as Id<'funcionarios'>)
: undefined,
setorId: saidaSetorId ? (saidaSetorId as Id<'setores'>) : undefined,
observacoes: saidaObservacoes.trim() || undefined
});
mostrarMensagem('success', 'Saída registrada com sucesso!');
// Limpar formulário
saidaMaterialId = '';
saidaQuantidade = 0;
saidaMotivo = '';
saidaFuncionarioId = '';
saidaSetorId = '';
saidaObservacoes = '';
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao registrar saída';
mostrarMensagem('error', message);
} finally {
saidaLoading = false;
}
}
async function ajustarEstoque() {
if (!ajusteMaterialId || ajusteQuantidadeNova < 0 || !ajusteMotivo.trim()) {
mostrarMensagem('error', 'Preencha todos os campos obrigatórios');
return;
}
try {
ajusteLoading = true;
await client.mutation(api.almoxarifado.ajustarEstoque, {
materialId: ajusteMaterialId as Id<'materiais'>,
quantidadeNova: ajusteQuantidadeNova,
motivo: ajusteMotivo.trim(),
observacoes: ajusteObservacoes.trim() || undefined
});
mostrarMensagem('success', 'Estoque ajustado com sucesso!');
// Limpar formulário
ajusteMaterialId = '';
ajusteQuantidadeNova = 0;
ajusteMotivo = '';
ajusteObservacoes = '';
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao ajustar estoque';
mostrarMensagem('error', message);
} finally {
ajusteLoading = false;
}
}
$effect(() => {
// Recarregar movimentações quando mudar de aba
if (abaAtiva === 'historico') {
// A query já está sendo executada
}
});
</script>
<main class="container mx-auto px-4 py-4">
<!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm">
<ul>
<li>
<a href={resolve('/recursos-humanos')} class="text-primary hover:underline"
>Recursos Humanos</a
>
</li>
<li>
<a href={resolve('/recursos-humanos/almoxarifado')} class="text-primary hover:underline"
>Almoxarifado</a
>
</li>
<li>Movimentações</li>
</ul>
</div>
<!-- Cabeçalho -->
<div class="mb-6">
<div class="flex items-center gap-4">
<div class="rounded-xl bg-info/20 p-3">
<ArrowLeftRight class="h-8 w-8 text-info" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold">Movimentações de Estoque</h1>
<p class="text-base-content/70">Registre entradas, saídas e ajustes de estoque</p>
</div>
</div>
</div>
<!-- Notificações -->
{#if notice}
<div class="alert alert-{notice.kind} mb-6 shadow-lg">
<span>{notice.text}</span>
</div>
{/if}
<!-- Abas -->
<div class="tabs tabs-boxed mb-6">
<button
class="tab {abaAtiva === 'entrada' ? 'tab-active' : ''}"
onclick={() => (abaAtiva = 'entrada')}
>
<ArrowDown class="h-5 w-5 mr-2" />
Entrada
</button>
<button
class="tab {abaAtiva === 'saida' ? 'tab-active' : ''}"
onclick={() => (abaAtiva = 'saida')}
>
<ArrowUp class="h-5 w-5 mr-2" />
Saída
</button>
<button
class="tab {abaAtiva === 'ajuste' ? 'tab-active' : ''}"
onclick={() => (abaAtiva = 'ajuste')}
>
<Settings class="h-5 w-5 mr-2" />
Ajuste
</button>
<button
class="tab {abaAtiva === 'historico' ? 'tab-active' : ''}"
onclick={() => (abaAtiva = 'historico')}
>
<History class="h-5 w-5 mr-2" />
Histórico
</button>
</div>
<!-- Conteúdo das Abas -->
{#if abaAtiva === 'entrada'}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Registrar Entrada de Material</h2>
<form onsubmit={(e) => { e.preventDefault(); registrarEntrada(); }}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-bold">Material *</span>
</label>
<select class="select select-bordered" bind:value={entradaMaterialId} required>
<option value="">Selecione um material</option>
{#if materiaisQuery.data}
{#each materiaisQuery.data as material}
<option value={material._id}>
{material.codigo} - {material.nome} (Estoque: {material.estoqueAtual}{material.unidadeMedida})
</option>
{/each}
{/if}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-bold">Quantidade *</span>
</label>
<input
type="number"
class="input input-bordered"
min="0.01"
step="0.01"
bind:value={entradaQuantidade}
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Documento (NF, etc.)</span>
</label>
<input
type="text"
class="input input-bordered"
placeholder="Número da nota fiscal"
bind:value={entradaDocumento}
/>
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-bold">Motivo *</span>
</label>
<input
type="text"
class="input input-bordered"
placeholder="Ex: Compra, Doação, Devolução"
bind:value={entradaMotivo}
required
/>
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Observações</span>
</label>
<textarea
class="textarea textarea-bordered"
placeholder="Observações adicionais (opcional)"
bind:value={entradaObservacoes}
rows="3"
></textarea>
</div>
</div>
<div class="card-actions mt-6 justify-end">
<button type="submit" class="btn btn-primary" disabled={entradaLoading}>
{#if entradaLoading}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<ArrowDown class="h-5 w-5" />
{/if}
Registrar Entrada
</button>
</div>
</form>
</div>
</div>
{:else if abaAtiva === 'saida'}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Registrar Saída de Material</h2>
<form onsubmit={(e) => { e.preventDefault(); registrarSaida(); }}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-bold">Material *</span>
</label>
<select class="select select-bordered" bind:value={saidaMaterialId} required>
<option value="">Selecione um material</option>
{#if materiaisQuery.data}
{#each materiaisQuery.data as material}
<option value={material._id}>
{material.codigo} - {material.nome} (Estoque: {material.estoqueAtual}{material.unidadeMedida})
</option>
{/each}
{/if}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-bold">Quantidade *</span>
</label>
<input
type="number"
class="input input-bordered"
min="0.01"
step="0.01"
bind:value={saidaQuantidade}
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Funcionário</span>
</label>
<select class="select select-bordered" bind:value={saidaFuncionarioId}>
<option value="">Selecione (opcional)</option>
{#if funcionariosQuery.data}
{#each funcionariosQuery.data as funcionario}
<option value={funcionario._id}>{funcionario.nome}</option>
{/each}
{/if}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Setor</span>
</label>
<select class="select select-bordered" bind:value={saidaSetorId}>
<option value="">Selecione (opcional)</option>
{#if setoresQuery.data}
{#each setoresQuery.data as setor}
<option value={setor._id}>{setor.nome}</option>
{/each}
{/if}
</select>
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-bold">Motivo *</span>
</label>
<input
type="text"
class="input input-bordered"
placeholder="Ex: Uso interno, Empréstimo, Descarte"
bind:value={saidaMotivo}
required
/>
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Observações</span>
</label>
<textarea
class="textarea textarea-bordered"
placeholder="Observações adicionais (opcional)"
bind:value={saidaObservacoes}
rows="3"
></textarea>
</div>
</div>
<div class="card-actions mt-6 justify-end">
<button type="submit" class="btn btn-primary" disabled={saidaLoading}>
{#if saidaLoading}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<ArrowUp class="h-5 w-5" />
{/if}
Registrar Saída
</button>
</div>
</form>
</div>
</div>
{:else if abaAtiva === 'ajuste'}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Ajustar Estoque</h2>
<div class="alert alert-warning mb-4">
<Settings class="h-6 w-6" />
<span>Ajustes de estoque devem ser justificados e são registrados no histórico.</span>
</div>
<form onsubmit={(e) => { e.preventDefault(); ajustarEstoque(); }}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-bold">Material *</span>
</label>
<select class="select select-bordered" bind:value={ajusteMaterialId} required>
<option value="">Selecione um material</option>
{#if materiaisQuery.data}
{#each materiaisQuery.data as material}
<option value={material._id}>
{material.codigo} - {material.nome} (Atual: {material.estoqueAtual}{material.unidadeMedida})
</option>
{/each}
{/if}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-bold">Nova Quantidade *</span>
</label>
<input
type="number"
class="input input-bordered"
min="0"
bind:value={ajusteQuantidadeNova}
required
/>
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-bold">Justificativa *</span>
</label>
<input
type="text"
class="input input-bordered"
placeholder="Ex: Inventário físico, Correção de erro, Perda"
bind:value={ajusteMotivo}
required
/>
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Observações</span>
</label>
<textarea
class="textarea textarea-bordered"
placeholder="Observações adicionais (opcional)"
bind:value={ajusteObservacoes}
rows="3"
></textarea>
</div>
</div>
<div class="card-actions mt-6 justify-end">
<button type="submit" class="btn btn-warning" disabled={ajusteLoading}>
{#if ajusteLoading}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Settings class="h-5 w-5" />
{/if}
Ajustar Estoque
</button>
</div>
</form>
</div>
</div>
{:else if abaAtiva === 'historico'}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Histórico de Movimentações</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Data</th>
<th>Material</th>
<th>Tipo</th>
<th>Quantidade</th>
<th>Anterior</th>
<th>Nova</th>
<th>Motivo</th>
</tr>
</thead>
<tbody>
{#if movimentacoesQuery.data && movimentacoesQuery.data.length > 0}
{#each movimentacoesQuery.data.slice(0, 50) as mov}
{@const material = materiaisQuery.data?.find(m => m._id === mov.materialId)}
<tr>
<td>{new Date(mov.data).toLocaleString('pt-BR')}</td>
<td>{material?.nome || 'Carregando...'}</td>
<td>
{#if mov.tipo === 'entrada'}
<span class="badge badge-success">Entrada</span>
{:else if mov.tipo === 'saida'}
<span class="badge badge-error">Saída</span>
{:else}
<span class="badge badge-warning">Ajuste</span>
{/if}
</td>
<td>{mov.quantidade}</td>
<td>{mov.quantidadeAnterior}</td>
<td>{mov.quantidadeNova}</td>
<td>{mov.motivo}</td>
</tr>
{/each}
{:else}
<tr>
<td colspan="7" class="text-center">
<div class="py-8">
<History class="mx-auto mb-4 h-12 w-12 text-base-content/30" />
<p class="text-base-content/70">Nenhuma movimentação registrada</p>
</div>
</td>
</tr>
{/if}
</tbody>
</table>
</div>
</div>
</div>
{/if}
</main>

View File

@@ -0,0 +1,313 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from 'convex-svelte';
import { resolve } from '$app/paths';
import {
BarChart3,
Package,
AlertTriangle,
ArrowLeftRight,
Download,
CheckCircle,
ArrowDown,
ArrowUp,
Settings
} from 'lucide-svelte';
const statsQuery = useQuery(api.almoxarifado.obterEstatisticas, {});
const materiaisQuery = useQuery(api.almoxarifado.listarMateriais, {});
const movimentacoesQuery = useQuery(api.almoxarifado.listarMovimentacoes, {});
const alertasQuery = useQuery(api.almoxarifado.listarAlertas, { status: 'ativo' });
// Agrupar materiais por categoria
const materiaisPorCategoria = $derived(() => {
if (!materiaisQuery.data) return {};
const agrupado: Record<string, number> = {};
materiaisQuery.data.forEach((m) => {
agrupado[m.categoria] = (agrupado[m.categoria] || 0) + 1;
});
return agrupado;
});
// Movimentações do mês
const movimentacoesMes = $derived(() => {
if (!movimentacoesQuery.data) return { entrada: 0, saida: 0, ajuste: 0 };
const agora = Date.now();
const inicioMes = new Date(agora);
inicioMes.setDate(1);
inicioMes.setHours(0, 0, 0, 0);
const movs = movimentacoesQuery.data.filter((m) => m.data >= inicioMes.getTime());
return {
entrada: movs.filter((m) => m.tipo === 'entrada').length,
saida: movs.filter((m) => m.tipo === 'saida').length,
ajuste: movs.filter((m) => m.tipo === 'ajuste').length
};
});
function exportarRelatorio(tipo: string) {
// Implementar exportação (CSV/Excel)
alert(`Exportação de ${tipo} será implementada em breve`);
}
</script>
<main class="container mx-auto px-4 py-4">
<!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm">
<ul>
<li>
<a href={resolve('/recursos-humanos')} class="text-primary hover:underline"
>Recursos Humanos</a
>
</li>
<li>
<a href={resolve('/recursos-humanos/almoxarifado')} class="text-primary hover:underline"
>Almoxarifado</a
>
</li>
<li>Relatórios</li>
</ul>
</div>
<!-- Cabeçalho -->
<div class="mb-6">
<div class="flex items-center gap-4">
<div class="rounded-xl bg-success/20 p-3">
<BarChart3 class="h-8 w-8 text-success" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold">Relatórios</h1>
<p class="text-base-content/70">Estatísticas e relatórios do almoxarifado</p>
</div>
</div>
</div>
<!-- Estatísticas Gerais -->
{#if statsQuery.data}
<div class="grid grid-cols-1 gap-4 md:grid-cols-4 mb-8">
<div class="stats bg-base-100 shadow-lg">
<div class="stat">
<div class="stat-figure text-primary">
<Package class="h-8 w-8" />
</div>
<div class="stat-title">Total de Materiais</div>
<div class="stat-value text-primary">{statsQuery.data.totalMateriais}</div>
<div class="stat-desc">Cadastrados no sistema</div>
</div>
</div>
<div class="stats bg-base-100 shadow-lg">
<div class="stat">
<div class="stat-figure text-success">
<CheckCircle class="h-8 w-8" />
</div>
<div class="stat-title">Materiais Ativos</div>
<div class="stat-value text-success">{statsQuery.data.totalMateriaisAtivos}</div>
<div class="stat-desc">Em estoque</div>
</div>
</div>
<div class="stats bg-base-100 shadow-lg">
<div class="stat">
<div class="stat-figure text-warning">
<AlertTriangle class="h-8 w-8" />
</div>
<div class="stat-title">Alertas Ativos</div>
<div class="stat-value text-warning">{statsQuery.data.totalAlertasAtivos}</div>
<div class="stat-desc">Estoque baixo</div>
</div>
</div>
<div class="stats bg-base-100 shadow-lg">
<div class="stat">
<div class="stat-figure text-info">
<ArrowLeftRight class="h-8 w-8" />
</div>
<div class="stat-title">Movimentações</div>
<div class="stat-value text-info">{statsQuery.data.movimentacoesMes}</div>
<div class="stat-desc">Este mês</div>
</div>
</div>
</div>
{/if}
<!-- Relatórios Disponíveis -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Relatório de Materiais por Categoria -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title">Materiais por Categoria</h2>
<button
class="btn btn-sm btn-ghost"
onclick={() => exportarRelatorio('materiais-categoria')}
>
<Download class="h-4 w-4" />
</button>
</div>
{#if materiaisQuery.data && Object.keys(materiaisPorCategoria).length > 0}
<div class="space-y-2">
{#each Object.entries(materiaisPorCategoria) as [categoria, quantidade]}
<div class="flex items-center justify-between">
<span class="font-medium">{categoria}</span>
<div class="flex items-center gap-2">
<div class="w-32">
<div class="h-2 bg-base-300 rounded-full overflow-hidden">
<div
class="h-full bg-primary transition-all"
style="width: {(quantidade / (materiaisQuery.data?.length || 1)) * 100}%"
></div>
</div>
</div>
<span class="text-sm font-bold w-12 text-right">{quantidade}</span>
</div>
</div>
{/each}
</div>
{:else}
<p class="text-base-content/70">Nenhum dado disponível</p>
{/if}
</div>
</div>
<!-- Movimentações do Mês -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title">Movimentações do Mês</h2>
<button
class="btn btn-sm btn-ghost"
onclick={() => exportarRelatorio('movimentacoes-mes')}
>
<Download class="h-4 w-4" />
</button>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<ArrowDown class="h-5 w-5 text-success" />
<span>Entradas</span>
</div>
<span class="font-bold text-success">{movimentacoesMes.entrada}</span>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<ArrowUp class="h-5 w-5 text-error" />
<span>Saídas</span>
</div>
<span class="font-bold text-error">{movimentacoesMes.saida}</span>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Settings class="h-5 w-5 text-warning" />
<span>Ajustes</span>
</div>
<span class="font-bold text-warning">{movimentacoesMes.ajuste}</span>
</div>
</div>
</div>
</div>
<!-- Materiais com Estoque Baixo -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title">Materiais com Estoque Baixo</h2>
<button
class="btn btn-sm btn-ghost"
onclick={() => exportarRelatorio('estoque-baixo')}
>
<Download class="h-4 w-4" />
</button>
</div>
{#if materiaisQuery.data}
{@const estoqueBaixo = materiaisQuery.data.filter(m => m.estoqueAtual <= m.estoqueMinimo)}
{#if estoqueBaixo.length > 0}
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Material</th>
<th>Atual</th>
<th>Mínimo</th>
</tr>
</thead>
<tbody>
{#each estoqueBaixo.slice(0, 10) as material}
<tr>
<td>
<div class="font-medium">{material.nome}</div>
<div class="text-xs text-base-content/60">{material.codigo}</div>
</td>
<td>
<span class="font-bold text-error">{material.estoqueAtual}</span>
</td>
<td>{material.estoqueMinimo}</td>
</tr>
{/each}
</tbody>
</table>
{#if estoqueBaixo.length > 10}
<p class="text-sm text-base-content/70 mt-2">
E mais {estoqueBaixo.length - 10} materiais...
</p>
{/if}
</div>
{:else}
<div class="alert alert-success">
<CheckCircle class="h-6 w-6" />
<span>Todos os materiais estão com estoque adequado!</span>
</div>
{/if}
{/if}
</div>
</div>
<!-- Alertas Recentes -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title">Alertas Recentes</h2>
<button
class="btn btn-sm btn-ghost"
onclick={() => exportarRelatorio('alertas')}
>
<Download class="h-4 w-4" />
</button>
</div>
{#if alertasQuery.data && alertasQuery.data.length > 0}
<div class="space-y-2">
{#each alertasQuery.data.slice(0, 5) as alerta}
<div class="flex items-center justify-between p-2 bg-warning/10 rounded">
<div>
<div class="font-medium text-sm">
{#if materiaisQuery.data}
{@const material = materiaisQuery.data.find(m => m._id === alerta.materialId)}
{material?.nome || 'Carregando...'}
{/if}
</div>
<div class="text-xs text-base-content/60">
{alerta.quantidadeAtual} / {alerta.quantidadeMinima}
</div>
</div>
<span class="badge badge-warning badge-sm">
{#if alerta.tipo === 'estoque_zerado'}
Zerado
{:else}
Mínimo
{/if}
</span>
</div>
{/each}
</div>
{:else}
<div class="alert alert-success">
<CheckCircle class="h-6 w-6" />
<span>Nenhum alerta ativo</span>
</div>
{/if}
</div>
</div>
</div>
</main>

View File

@@ -0,0 +1,463 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { resolve } from '$app/paths';
import { ClipboardList, Plus, CheckCircle, XCircle, Package } from 'lucide-svelte';
const client = useConvexClient();
let requisicoes = $state<Array<Doc<'requisicoesMaterial'>>>([]);
let filtroStatus = $state<string>('');
let showModalNova = $state(false);
// Estados do formulário de nova requisição
let novaRequisicaoSolicitanteId = $state<Id<'funcionarios'> | ''>('');
let novaRequisicaoSetorId = $state<Id<'setores'> | ''>('');
let novaRequisicaoItens = $state<
Array<{
id: string;
materialId: Id<'materiais'> | '';
quantidade: number;
observacoes: string;
}>
>([]);
let novoItemMaterialId = $state<Id<'materiais'> | ''>('');
let novoItemQuantidade = $state(0);
let novoItemObservacoes = $state('');
let novaRequisicaoObservacoes = $state('');
let criandoRequisicao = $state(false);
const requisicoesQuery = useQuery(api.almoxarifado.listarRequisicoes, {});
const materiaisQuery = useQuery(api.almoxarifado.listarMateriais, { ativo: true });
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const setoresQuery = useQuery(api.setores.list, {});
let notice = $state<{ kind: 'success' | 'error'; text: string } | null>(null);
function mostrarMensagem(kind: 'success' | 'error', text: string) {
notice = { kind, text };
setTimeout(() => {
notice = null;
}, 5000);
}
$effect(() => {
if (requisicoesQuery.data) {
requisicoes = requisicoesQuery.data;
if (filtroStatus) {
requisicoes = requisicoes.filter((r) => r.status === filtroStatus);
}
}
});
$effect(() => {
if (requisicoesQuery.data) {
requisicoes = filtroStatus
? requisicoesQuery.data.filter((r) => r.status === filtroStatus)
: requisicoesQuery.data;
}
});
function adicionarItem() {
if (!novoItemMaterialId || novoItemQuantidade <= 0) {
mostrarMensagem('error', 'Selecione um material e informe a quantidade');
return;
}
novaRequisicaoItens = [
...novaRequisicaoItens,
{
id: crypto.randomUUID(),
materialId: novoItemMaterialId as Id<'materiais'>,
quantidade: novoItemQuantidade,
observacoes: novoItemObservacoes
}
];
novoItemMaterialId = '';
novoItemQuantidade = 0;
novoItemObservacoes = '';
}
function removerItem(id: string) {
novaRequisicaoItens = novaRequisicaoItens.filter((item) => item.id !== id);
}
function abrirModalNova() {
showModalNova = true;
novaRequisicaoSolicitanteId = '';
novaRequisicaoSetorId = '';
novaRequisicaoItens = [];
novaRequisicaoObservacoes = '';
}
function fecharModalNova() {
showModalNova = false;
}
async function criarRequisicao() {
if (!novaRequisicaoSolicitanteId || !novaRequisicaoSetorId || novaRequisicaoItens.length === 0) {
mostrarMensagem('error', 'Preencha todos os campos obrigatórios e adicione pelo menos um item');
return;
}
try {
criandoRequisicao = true;
await client.mutation(api.almoxarifado.criarRequisicao, {
solicitanteId: novaRequisicaoSolicitanteId as Id<'funcionarios'>,
setorId: novaRequisicaoSetorId as Id<'setores'>,
itens: novaRequisicaoItens.map((item) => ({
materialId: item.materialId as Id<'materiais'>,
quantidadeSolicitada: item.quantidade,
observacoes: item.observacoes || undefined
})),
observacoes: novaRequisicaoObservacoes || undefined
});
mostrarMensagem('success', 'Requisição criada com sucesso!');
fecharModalNova();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao criar requisição';
mostrarMensagem('error', message);
} finally {
criandoRequisicao = false;
}
}
async function aprovarRequisicao(id: Id<'requisicoesMaterial'>) {
try {
await client.mutation(api.almoxarifado.aprovarRequisicao, { id });
mostrarMensagem('success', 'Requisição aprovada com sucesso!');
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao aprovar requisição';
mostrarMensagem('error', message);
}
}
async function atenderRequisicao(id: Id<'requisicoesMaterial'>) {
if (!confirm('Tem certeza que deseja atender esta requisição? Isso registrará as saídas de material.')) {
return;
}
try {
await client.mutation(api.almoxarifado.atenderRequisicao, { id });
mostrarMensagem('success', 'Requisição atendida com sucesso!');
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao atender requisição';
mostrarMensagem('error', message);
}
}
function getStatusBadge(status: string) {
switch (status) {
case 'pendente':
return 'badge-warning';
case 'aprovada':
return 'badge-info';
case 'atendida':
return 'badge-success';
case 'rejeitada':
return 'badge-error';
case 'cancelada':
return 'badge-ghost';
default:
return 'badge-ghost';
}
}
function getStatusLabel(status: string) {
switch (status) {
case 'pendente':
return 'Pendente';
case 'aprovada':
return 'Aprovada';
case 'atendida':
return 'Atendida';
case 'rejeitada':
return 'Rejeitada';
case 'cancelada':
return 'Cancelada';
default:
return status;
}
}
</script>
<main class="container mx-auto px-4 py-4">
<!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm">
<ul>
<li>
<a href={resolve('/recursos-humanos')} class="text-primary hover:underline"
>Recursos Humanos</a
>
</li>
<li>
<a href={resolve('/recursos-humanos/almoxarifado')} class="text-primary hover:underline"
>Almoxarifado</a
>
</li>
<li>Requisições</li>
</ul>
</div>
<!-- Cabeçalho -->
<div class="mb-6">
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div class="flex items-center gap-4">
<div class="rounded-xl bg-purple-500/20 p-3">
<ClipboardList class="h-8 w-8 text-purple-600" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold">Requisições de Material</h1>
<p class="text-base-content/70">Gerencie requisições de material dos funcionários</p>
</div>
</div>
<button class="btn btn-primary" onclick={abrirModalNova}>
<Plus class="h-5 w-5" />
Nova Requisição
</button>
</div>
</div>
<!-- Notificações -->
{#if notice}
<div class="alert alert-{notice.kind} mb-6 shadow-lg">
<span>{notice.text}</span>
</div>
{/if}
<!-- Filtros -->
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<div class="form-control">
<label class="label">
<span class="label-text">Filtrar por Status</span>
</label>
<select class="select select-bordered" bind:value={filtroStatus}>
<option value="">Todos</option>
<option value="pendente">Pendente</option>
<option value="aprovada">Aprovada</option>
<option value="atendida">Atendida</option>
<option value="rejeitada">Rejeitada</option>
<option value="cancelada">Cancelada</option>
</select>
</div>
</div>
</div>
<!-- Lista de Requisições -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Número</th>
<th>Solicitante</th>
<th>Setor</th>
<th>Status</th>
<th>Data</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#if requisicoes.length === 0}
<tr>
<td colspan="6" class="text-center">
<div class="py-8">
<ClipboardList class="mx-auto mb-4 h-12 w-12 text-base-content/30" />
<p class="text-base-content/70">Nenhuma requisição encontrada</p>
</div>
</td>
</tr>
{:else}
{#each requisicoes as requisicao}
{@const solicitante = funcionariosQuery.data?.find(f => f._id === requisicao.solicitanteId)}
{@const setor = setoresQuery.data?.find(s => s._id === requisicao.setorId)}
<tr>
<td>
<div class="font-mono font-bold">{requisicao.numero}</div>
</td>
<td>{solicitante?.nome || 'Carregando...'}</td>
<td>{setor?.nome || 'Carregando...'}</td>
<td>
<span class="badge {getStatusBadge(requisicao.status)}">
{getStatusLabel(requisicao.status)}
</span>
</td>
<td>{new Date(requisicao.criadoEm).toLocaleDateString('pt-BR')}</td>
<td>
<div class="flex gap-2">
{#if requisicao.status === 'pendente'}
<button
class="btn btn-sm btn-success"
onclick={() => aprovarRequisicao(requisicao._id)}
>
<CheckCircle class="h-4 w-4" />
Aprovar
</button>
{:else if requisicao.status === 'aprovada'}
<button
class="btn btn-sm btn-primary"
onclick={() => atenderRequisicao(requisicao._id)}
>
<Package class="h-4 w-4" />
Atender
</button>
{/if}
</div>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal Nova Requisição -->
{#if showModalNova}
<div class="modal modal-open">
<div class="modal-box max-w-4xl">
<h3 class="text-lg font-bold">Nova Requisição de Material</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 mt-4">
<div class="form-control">
<label class="label">
<span class="label-text font-bold">Solicitante *</span>
</label>
<select class="select select-bordered" bind:value={novaRequisicaoSolicitanteId} required>
<option value="">Selecione um funcionário</option>
{#if funcionariosQuery.data}
{#each funcionariosQuery.data as funcionario}
<option value={funcionario._id}>{funcionario.nome}</option>
{/each}
{/if}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-bold">Setor *</span>
</label>
<select class="select select-bordered" bind:value={novaRequisicaoSetorId} required>
<option value="">Selecione um setor</option>
{#if setoresQuery.data}
{#each setoresQuery.data as setor}
<option value={setor._id}>{setor.nome}</option>
{/each}
{/if}
</select>
</div>
</div>
<div class="divider">Itens da Requisição</div>
<!-- Adicionar Item -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 mb-4">
<div class="form-control">
<label class="label">
<span class="label-text">Material</span>
</label>
<select class="select select-bordered" bind:value={novoItemMaterialId}>
<option value="">Selecione</option>
{#if materiaisQuery.data}
{#each materiaisQuery.data as material}
<option value={material._id}>
{material.codigo} - {material.nome}
</option>
{/each}
{/if}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Quantidade</span>
</label>
<input
type="number"
class="input input-bordered"
min="0.01"
step="0.01"
bind:value={novoItemQuantidade}
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Ações</span>
</label>
<button type="button" class="btn btn-primary" onclick={adicionarItem}>
<Plus class="h-4 w-4" />
Adicionar
</button>
</div>
</div>
<!-- Lista de Itens -->
{#if novaRequisicaoItens.length > 0}
<div class="overflow-x-auto mb-4">
<table class="table table-sm">
<thead>
<tr>
<th>Material</th>
<th>Quantidade</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each novaRequisicaoItens as item}
{@const material = materiaisQuery.data?.find(m => m._id === item.materialId)}
<tr>
<td>{material?.nome || 'Carregando...'}</td>
<td>{item.quantidade} {material?.unidadeMedida || ''}</td>
<td>
<button
class="btn btn-sm btn-ghost"
onclick={() => removerItem(item.id)}
>
<XCircle class="h-4 w-4" />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<div class="form-control mt-4">
<label class="label">
<span class="label-text">Observações</span>
</label>
<textarea
class="textarea textarea-bordered"
placeholder="Observações gerais da requisição (opcional)"
bind:value={novaRequisicaoObservacoes}
rows="3"
></textarea>
</div>
<div class="modal-action">
<button class="btn btn-ghost" onclick={fecharModalNova} disabled={criandoRequisicao}>
Cancelar
</button>
<button class="btn btn-primary" onclick={criarRequisicao} disabled={criandoRequisicao}>
{#if criandoRequisicao}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Plus class="h-5 w-5" />
{/if}
Criar Requisição
</button>
</div>
</div>
</div>
{/if}
</main>

View File

@@ -50,6 +50,7 @@
| '/(dashboard)/ti/configuracoes-ponto'
| '/(dashboard)/ti/configuracoes-relogio'
| '/(dashboard)/ti/configuracoes-jitsi'
| '/(dashboard)/ti/configuracoes-almoxarifado'
| '/(dashboard)/configuracoes/setores';
type FeatureCard = {
@@ -278,6 +279,19 @@
palette: 'info',
icon: 'clock'
},
{
title: 'Configurações de Almoxarifado',
description:
'Configure parâmetros do sistema de almoxarifado, alertas e regras de estoque. Acesso restrito à TI.',
ctaLabel: 'Configurar Almoxarifado',
href: '/(dashboard)/ti/configuracoes-almoxarifado',
palette: 'warning',
icon: 'control',
highlightBadges: [
{ label: 'Restrito', variant: 'solid' },
{ label: 'TI Only', variant: 'outline' }
]
},
{
title: 'Monitoramento de Emails',
description:

View File

@@ -0,0 +1,357 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useConvexClient, useQuery } from 'convex-svelte';
import { resolve } from '$app/paths';
import { Settings, Save, AlertTriangle, Info } from 'lucide-svelte';
const client = useConvexClient();
const configAtual = useQuery(api.configuracaoAlmoxarifado.obterConfiguracao, {});
// Estados do formulário
let estoqueMinimoPadrao = $state(10);
let diasAntecedenciaAlerta = $state(7);
let permitirEstoqueNegativo = $state(false);
let requerAprovacaoRequisicao = $state(true);
let rolesAprovacao = $state<string[]>([]);
let emailAlertasAtivo = $state(false);
let emailsDestinatarios = $state<string[]>([]);
let novoEmail = $state('');
let periodicidadeInventario = $state(30);
let processando = $state(false);
let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null);
let dataLoaded = $state(false);
// Carregar config existente
$effect(() => {
if (configAtual?.data && !dataLoaded) {
estoqueMinimoPadrao = configAtual.data.estoqueMinimoPadrao || 10;
diasAntecedenciaAlerta = configAtual.data.diasAntecedenciaAlerta || 7;
permitirEstoqueNegativo = configAtual.data.permitirEstoqueNegativo || false;
requerAprovacaoRequisicao = configAtual.data.requerAprovacaoRequisicao || true;
rolesAprovacao = configAtual.data.rolesAprovacao || [];
emailAlertasAtivo = configAtual.data.emailAlertasAtivo || false;
emailsDestinatarios = configAtual.data.emailsDestinatarios || [];
periodicidadeInventario = configAtual.data.periodicidadeInventario || 30;
dataLoaded = true;
}
});
function mostrarMensagem(tipo: 'success' | 'error', texto: string) {
mensagem = { tipo, texto };
setTimeout(() => {
mensagem = null;
}, 5000);
}
function adicionarEmail() {
if (novoEmail.trim() && !emailsDestinatarios.includes(novoEmail.trim())) {
emailsDestinatarios = [...emailsDestinatarios, novoEmail.trim()];
novoEmail = '';
}
}
function removerEmail(email: string) {
emailsDestinatarios = emailsDestinatarios.filter((e) => e !== email);
}
async function salvarConfiguracao() {
// Validações
if (estoqueMinimoPadrao < 0) {
mostrarMensagem('error', 'Estoque mínimo padrão não pode ser negativo');
return;
}
if (diasAntecedenciaAlerta < 0) {
mostrarMensagem('error', 'Dias de antecedência não pode ser negativo');
return;
}
if (periodicidadeInventario < 1) {
mostrarMensagem('error', 'Periodicidade de inventário deve ser pelo menos 1 dia');
return;
}
if (emailAlertasAtivo && emailsDestinatarios.length === 0) {
mostrarMensagem('error', 'Adicione pelo menos um email destinatário se os alertas por email estiverem ativos');
return;
}
processando = true;
try {
await client.mutation(api.configuracaoAlmoxarifado.atualizarConfiguracao, {
estoqueMinimoPadrao,
diasAntecedenciaAlerta,
permitirEstoqueNegativo,
requerAprovacaoRequisicao,
rolesAprovacao,
emailAlertasAtivo,
emailsDestinatarios,
periodicidadeInventario
});
mostrarMensagem('success', 'Configuração salva com sucesso!');
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao salvar configuração';
mostrarMensagem('error', message);
} finally {
processando = false;
}
}
</script>
<main class="container mx-auto px-4 py-4">
<!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm">
<ul>
<li>
<a href={resolve('/ti')} class="text-primary hover:underline">TI</a>
</li>
<li>Configurações de Almoxarifado</li>
</ul>
</div>
<!-- Cabeçalho -->
<div class="mb-6">
<div class="flex items-center gap-4">
<div class="rounded-xl bg-warning/20 p-3">
<Settings class="h-8 w-8 text-warning" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold">Configurações de Almoxarifado</h1>
<p class="text-base-content/70">
Configure parâmetros do sistema de almoxarifado. Acesso restrito à TI.
</p>
</div>
</div>
</div>
<!-- Alerta de Acesso Restrito -->
<div class="alert alert-warning mb-6 shadow-lg">
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
<div>
<h3 class="font-bold">Acesso Restrito</h3>
<div class="text-sm">
Esta página é restrita apenas para usuários com permissão de TI. Alterações aqui afetam
o comportamento de todo o sistema de almoxarifado.
</div>
</div>
</div>
<!-- Notificações -->
{#if mensagem}
<div class="alert alert-{mensagem.tipo} mb-6 shadow-lg">
<span>{mensagem.texto}</span>
</div>
{/if}
<!-- Formulário -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<form onsubmit={(e) => { e.preventDefault(); salvarConfiguracao(); }}>
<!-- Configurações Gerais -->
<div class="divider">
<h2 class="text-xl font-bold">Configurações Gerais</h2>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text font-bold">Estoque Mínimo Padrão</span>
</label>
<input
type="number"
class="input input-bordered"
min="0"
bind:value={estoqueMinimoPadrao}
required
/>
<label class="label">
<span class="label-text-alt"
>Valor padrão usado para novos materiais quando não especificado</span
>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-bold">Dias de Antecedência para Alerta</span>
</label>
<input
type="number"
class="input input-bordered"
min="0"
bind:value={diasAntecedenciaAlerta}
required
/>
<label class="label">
<span class="label-text-alt"
>Dias antes do estoque mínimo para gerar alerta</span
>
</label>
</div>
<div class="form-control md:col-span-2">
<label class="label cursor-pointer justify-start gap-2">
<input
type="checkbox"
class="checkbox checkbox-warning"
bind:checked={permitirEstoqueNegativo}
/>
<span class="label-text font-bold">Permitir Estoque Negativo</span>
</label>
<label class="label">
<span class="label-text-alt"
>Permite registrar saídas mesmo quando o estoque é insuficiente</span
>
</label>
</div>
</div>
<!-- Configurações de Requisições -->
<div class="divider">
<h2 class="text-xl font-bold">Requisições</h2>
</div>
<div class="grid grid-cols-1 gap-6">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-2">
<input
type="checkbox"
class="checkbox checkbox-warning"
bind:checked={requerAprovacaoRequisicao}
/>
<span class="label-text font-bold">Requer Aprovação de Requisições</span>
</label>
<label class="label">
<span class="label-text-alt"
>Se ativado, requisições precisam ser aprovadas antes de serem atendidas</span
>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Roles que Podem Aprovar</span>
</label>
<div class="alert alert-info">
<Info class="h-5 w-5" />
<span class="text-sm"
>Configure as roles no painel de permissões. Roles com permissão
'almoxarifado.aprovar_requisicao' podem aprovar requisições.</span
>
</div>
</div>
</div>
<!-- Alertas e Notificações -->
<div class="divider">
<h2 class="text-xl font-bold">Alertas e Notificações</h2>
</div>
<div class="grid grid-cols-1 gap-6">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-2">
<input
type="checkbox"
class="checkbox checkbox-warning"
bind:checked={emailAlertasAtivo}
/>
<span class="label-text font-bold">Ativar Alertas por Email</span>
</label>
<label class="label">
<span class="label-text-alt"
>Envia emails quando alertas de estoque são gerados</span
>
</label>
</div>
{#if emailAlertasAtivo}
<div class="form-control">
<label class="label">
<span class="label-text font-bold">Emails Destinatários</span>
</label>
<div class="flex gap-2 mb-2">
<input
type="email"
class="input input-bordered flex-1"
placeholder="email@exemplo.com"
bind:value={novoEmail}
onkeypress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
adicionarEmail();
}
}}
/>
<button type="button" class="btn btn-primary" onclick={adicionarEmail}>
Adicionar
</button>
</div>
{#if emailsDestinatarios.length > 0}
<div class="flex flex-wrap gap-2">
{#each emailsDestinatarios as email}
<div class="badge badge-lg gap-2">
{email}
<button
type="button"
class="btn btn-ghost btn-xs"
onclick={() => removerEmail(email)}
>
×
</button>
</div>
{/each}
</div>
{:else}
<div class="alert alert-warning">
<AlertTriangle class="h-5 w-5" />
<span class="text-sm">Nenhum email adicionado</span>
</div>
{/if}
</div>
{/if}
</div>
<!-- Inventário -->
<div class="divider">
<h2 class="text-xl font-bold">Inventário</h2>
</div>
<div class="grid grid-cols-1 gap-6">
<div class="form-control">
<label class="label">
<span class="label-text font-bold">Periodicidade de Inventário (dias)</span>
</label>
<input
type="number"
class="input input-bordered"
min="1"
bind:value={periodicidadeInventario}
required
/>
<label class="label">
<span class="label-text-alt"
>Intervalo recomendado entre inventários físicos</span
>
</label>
</div>
</div>
<!-- Botões -->
<div class="card-actions mt-6 justify-end">
<button type="submit" class="btn btn-warning" disabled={processando}>
{#if processando}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Save class="h-5 w-5" />
{/if}
Salvar Configurações
</button>
</div>
</form>
</div>
</div>
</main>