Ajustes final etapa1 #69

Merged
deyvisonwanderley merged 22 commits from ajustes_final_etapa1 into master 2025-12-22 17:29:58 +00:00
37 changed files with 5032 additions and 61 deletions
Showing only changes of commit 367cda7b95 - Show all commits

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { AlertTriangle, Package } from 'lucide-svelte';
interface Props {
alerta: {
_id: Id<'alertasEstoque'>;
materialId: Id<'materiais'>;
tipo: 'estoque_minimo' | 'estoque_zerado' | 'reposicao_necessaria';
quantidadeAtual: number;
quantidadeMinima: number;
status: 'ativo' | 'resolvido' | 'ignorado';
criadoEm: number;
};
materialNome?: string;
materialCodigo?: string;
}
let { alerta, materialNome = 'Carregando...', materialCodigo = '' }: Props = $props();
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;
}
}
{@const diferenca = alerta.quantidadeMinima - alerta.quantidadeAtual}
</script>
<div class="card bg-base-100 shadow-lg border-2 {alerta.status === 'ativo' ? 'border-warning' : 'border-base-300'}">
<div class="card-body">
<div class="flex items-start justify-between mb-2">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<AlertTriangle class="h-5 w-5 text-warning" />
<h3 class="card-title text-lg">{materialNome}</h3>
</div>
{#if materialCodigo}
<p class="text-sm text-base-content/60 font-mono mb-2">Código: {materialCodigo}</p>
{/if}
</div>
<span class="badge {getTipoBadge(alerta.tipo)}">{getTipoLabel(alerta.tipo)}</span>
</div>
<div class="divider my-2"></div>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-xs text-base-content/60 mb-1">Quantidade Atual</p>
<p class="text-2xl font-bold text-error">{alerta.quantidadeAtual}</p>
</div>
<div>
<p class="text-xs text-base-content/60 mb-1">Quantidade Mínima</p>
<p class="text-xl font-medium">{alerta.quantidadeMinima}</p>
</div>
</div>
<div class="mt-2">
<p class="text-xs text-base-content/60 mb-1">Faltam</p>
<p class="text-lg font-bold text-warning">{diferenca} unidades</p>
</div>
<div class="mt-2 text-xs text-base-content/60">
Criado em: {new Date(alerta.criadoEm).toLocaleString('pt-BR')}
</div>
</div>
</div>

View File

@@ -0,0 +1,51 @@
<script lang="ts">
interface Props {
estoqueAtual: number;
estoqueMinimo: number;
estoqueMaximo?: number;
unidadeMedida: string;
}
let { estoqueAtual, estoqueMinimo, estoqueMaximo, unidadeMedida }: Props = $props();
{@const porcentagem = estoqueMaximo
? Math.min(100, (estoqueAtual / estoqueMaximo) * 100)
: estoqueAtual > estoqueMinimo
? 100
: Math.max(0, (estoqueAtual / estoqueMinimo) * 100)}
{@const cor = estoqueAtual <= estoqueMinimo
? 'text-error'
: estoqueMaximo && estoqueAtual >= estoqueMaximo * 0.8
? 'text-warning'
: 'text-success'}
{@const corBarra = estoqueAtual <= estoqueMinimo
? 'bg-error'
: estoqueMaximo && estoqueAtual >= estoqueMaximo * 0.8
? 'bg-warning'
: 'bg-success'}
</script>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">Estoque</span>
<span class="text-sm font-bold {cor}">
{estoqueAtual} {unidadeMedida}
</span>
</div>
<div class="w-full bg-base-300 rounded-full h-3 overflow-hidden">
<div
class="h-full {corBarra} transition-all duration-500"
style="width: {porcentagem}%"
></div>
</div>
<div class="flex items-center justify-between text-xs text-base-content/60">
<span>Mín: {estoqueMinimo}</span>
{#if estoqueMaximo}
<span>Máx: {estoqueMaximo}</span>
{/if}
</div>
</div>

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import { Clock, User, FileText } from 'lucide-svelte';
interface HistoricoItem {
acao: string;
usuarioId: string;
usuarioNome?: string;
timestamp: number;
observacoes?: string;
dadosAnteriores?: string;
dadosNovos?: string;
}
interface Props {
historico: HistoricoItem[];
}
let { historico }: Props = $props();
function getAcaoLabel(acao: string) {
switch (acao) {
case 'criacao':
return 'Criação';
case 'edicao':
return 'Edição';
case 'exclusao':
return 'Exclusão';
case 'movimentacao':
return 'Movimentação';
default:
return acao;
}
}
function getAcaoColor(acao: string) {
switch (acao) {
case 'criacao':
return 'text-success';
case 'edicao':
return 'text-info';
case 'exclusao':
return 'text-error';
case 'movimentacao':
return 'text-warning';
default:
return 'text-base-content';
}
}
</script>
<div class="space-y-4">
{#each historico as item, index}
<div class="flex gap-4">
<!-- Linha vertical -->
{#if index < historico.length - 1}
<div class="flex flex-col items-center">
<div class="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center">
<Clock class="h-6 w-6 text-primary" />
</div>
<div class="w-0.5 h-full bg-base-300 my-2"></div>
</div>
{:else}
<div class="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center">
<Clock class="h-6 w-6 text-primary" />
</div>
{/if}
<!-- Conteúdo -->
<div class="flex-1 pb-4">
<div class="card bg-base-100 shadow">
<div class="card-body p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<User class="h-4 w-4 text-base-content/60" />
<span class="font-medium">{item.usuarioNome || 'Usuário'}</span>
</div>
<span class="badge {getAcaoColor(item.acao)} badge-outline">
{getAcaoLabel(item.acao)}
</span>
</div>
<p class="text-sm text-base-content/60 mb-2">
{new Date(item.timestamp).toLocaleString('pt-BR')}
</p>
{#if item.observacoes}
<div class="flex items-start gap-2 mt-2">
<FileText class="h-4 w-4 text-base-content/60 mt-0.5" />
<p class="text-sm text-base-content/70">{item.observacoes}</p>
</div>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { Package, AlertTriangle } from 'lucide-svelte';
interface Props {
material: {
_id: Id<'materiais'>;
codigo: string;
nome: string;
descricao?: string;
categoria: string;
estoqueAtual: number;
estoqueMinimo: number;
unidadeMedida: string;
ativo: boolean;
};
}
let { material }: Props = $props();
</script>
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow">
<div class="card-body">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<Package class="h-5 w-5 text-primary" />
<h3 class="card-title text-lg">{material.nome}</h3>
</div>
<p class="text-sm text-base-content/60 font-mono mb-1">Código: {material.codigo}</p>
{#if material.descricao}
<p class="text-sm text-base-content/70 mb-2">{material.descricao}</p>
{/if}
<div class="flex items-center gap-2 mb-2">
<span class="badge badge-outline">{material.categoria}</span>
{#if material.ativo}
<span class="badge badge-success">Ativo</span>
{:else}
<span class="badge badge-error">Inativo</span>
{/if}
</div>
</div>
</div>
<div class="divider my-2"></div>
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-base-content/60">Estoque Atual</p>
<div class="flex items-center gap-2">
<p class="text-2xl font-bold {material.estoqueAtual <= material.estoqueMinimo ? 'text-error' : 'text-success'}">
{material.estoqueAtual}
</p>
<span class="text-sm text-base-content/60">{material.unidadeMedida}</span>
{#if material.estoqueAtual <= material.estoqueMinimo}
<AlertTriangle class="h-5 w-5 text-warning" />
{/if}
</div>
</div>
<div class="text-right">
<p class="text-xs text-base-content/60">Mínimo</p>
<p class="text-lg font-medium">{material.estoqueMinimo} {material.unidadeMedida}</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,219 @@
<script lang="ts">
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { ArrowDown, ArrowUp, Settings } from 'lucide-svelte';
interface Props {
tipo: 'entrada' | 'saida' | 'ajuste';
materialId?: Id<'materiais'> | '';
onSubmit: (data: {
materialId: Id<'materiais'>;
quantidade: number;
motivo: string;
documento?: string;
funcionarioId?: Id<'funcionarios'>;
setorId?: Id<'setores'>;
observacoes?: string;
quantidadeNova?: number;
}) => Promise<void>;
materiais?: Array<{
_id: Id<'materiais'>;
codigo: string;
nome: string;
estoqueAtual: number;
unidadeMedida: string;
}>;
funcionarios?: Array<{
_id: Id<'funcionarios'>;
nome: string;
}>;
setores?: Array<{
_id: Id<'setores'>;
nome: string;
}>;
loading?: boolean;
}
let {
tipo,
materialId = '',
onSubmit,
materiais = [],
funcionarios = [],
setores = [],
loading = false
}: Props = $props();
let quantidade = $state(0);
let quantidadeNova = $state(0);
let motivo = $state('');
let documento = $state('');
let funcionarioId = $state<Id<'funcionarios'> | ''>('');
let setorId = $state<Id<'setores'> | ''>('');
let observacoes = $state('');
async function handleSubmit() {
if (!materialId || !motivo.trim()) {
return;
}
if (tipo === 'ajuste') {
if (quantidadeNova < 0) {
return;
}
await onSubmit({
materialId: materialId as Id<'materiais'>,
quantidadeNova,
motivo: motivo.trim(),
observacoes: observacoes.trim() || undefined
});
} else {
if (quantidade <= 0) {
return;
}
await onSubmit({
materialId: materialId as Id<'materiais'>,
quantidade,
motivo: motivo.trim(),
documento: documento.trim() || undefined,
funcionarioId: funcionarioId ? (funcionarioId as Id<'funcionarios'>) : undefined,
setorId: setorId ? (setorId as Id<'setores'>) : undefined,
observacoes: observacoes.trim() || undefined
});
}
// Limpar formulário
quantidade = 0;
quantidadeNova = 0;
motivo = '';
documento = '';
funcionarioId = '';
setorId = '';
observacoes = '';
}
</script>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<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={materialId} required>
<option value="">Selecione um material</option>
{#each materiais as material}
<option value={material._id}>
{material.codigo} - {material.nome} (Estoque: {material.estoqueAtual}{material.unidadeMedida})
</option>
{/each}
</select>
</div>
{#if tipo === 'ajuste'}
<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={quantidadeNova}
required
/>
</div>
{:else}
<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={quantidade}
required
/>
</div>
{/if}
{#if tipo === 'entrada'}
<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={documento}
/>
</div>
{:else if tipo === 'saida'}
<div class="form-control">
<label class="label">
<span class="label-text">Funcionário</span>
</label>
<select class="select select-bordered" bind:value={funcionarioId}>
<option value="">Selecione (opcional)</option>
{#each funcionarios as funcionario}
<option value={funcionario._id}>{funcionario.nome}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Setor</span>
</label>
<select class="select select-bordered" bind:value={setorId}>
<option value="">Selecione (opcional)</option>
{#each setores as setor}
<option value={setor._id}>{setor.nome}</option>
{/each}
</select>
</div>
{/if}
<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={tipo === 'entrada' ? 'Ex: Compra, Doação' : tipo === 'saida' ? 'Ex: Uso interno' : 'Ex: Inventário físico'}
bind:value={motivo}
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={observacoes}
rows="3"
></textarea>
</div>
</div>
<div class="card-actions mt-6 justify-end">
<button type="submit" class="btn {tipo === 'ajuste' ? 'btn-warning' : tipo === 'entrada' ? 'btn-success' : 'btn-error'}" disabled={loading}>
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
{:else if tipo === 'entrada'}
<ArrowDown class="h-5 w-5" />
{:else if tipo === 'saida'}
<ArrowUp class="h-5 w-5" />
{:else}
<Settings class="h-5 w-5" />
{/if}
Registrar {tipo === 'entrada' ? 'Entrada' : tipo === 'saida' ? 'Saída' : 'Ajuste'}
</button>
</div>
</form>

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>

View File

@@ -62,6 +62,7 @@ import type * as security from "../security.js";
import type * as seed from "../seed.js";
import type * as setores from "../setores.js";
import type * as simbolos from "../simbolos.js";
import type * as tables_almoxarifado from "../tables/almoxarifado.js";
import type * as tables_atas from "../tables/atas.js";
import type * as tables_atestados from "../tables/atestados.js";
import type * as tables_ausencias from "../tables/ausencias.js";
@@ -156,6 +157,7 @@ declare const fullApi: ApiFromModules<{
seed: typeof seed;
setores: typeof setores;
simbolos: typeof simbolos;
"tables/almoxarifado": typeof tables_almoxarifado;
"tables/atas": typeof tables_atas;
"tables/atestados": typeof tables_atestados;
"tables/ausencias": typeof tables_ausencias;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,165 @@
import { v } from 'convex/values';
import { internal, internalQuery, mutation, query } from './_generated/server';
import { getCurrentUserFunction } from './auth';
export const obterConfiguracao = query({
args: {},
handler: async (ctx) => {
// Verificar se usuário tem permissão de TI
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'almoxarifado',
acao: 'configurar'
});
const config = await ctx.db
.query('configuracoesAlmoxarifado')
.filter((q) => q.eq(q.field('ativo'), true))
.first();
// Se não existe configuração, retornar valores padrão
if (!config) {
return {
estoqueMinimoPadrao: 10,
diasAntecedenciaAlerta: 7,
permitirEstoqueNegativo: false,
requerAprovacaoRequisicao: true,
rolesAprovacao: [],
emailAlertasAtivo: false,
emailsDestinatarios: [],
periodicidadeInventario: 30,
ultimoInventario: undefined,
ativo: true
};
}
return config;
}
});
export const atualizarConfiguracao = mutation({
args: {
estoqueMinimoPadrao: v.optional(v.number()),
diasAntecedenciaAlerta: v.optional(v.number()),
permitirEstoqueNegativo: v.optional(v.boolean()),
requerAprovacaoRequisicao: v.optional(v.boolean()),
rolesAprovacao: v.optional(v.array(v.string())),
emailAlertasAtivo: v.optional(v.boolean()),
emailsDestinatarios: v.optional(v.array(v.string())),
periodicidadeInventario: v.optional(v.number()),
ultimoInventario: v.optional(v.number())
},
handler: async (ctx, args) => {
// Verificar se usuário tem permissão de TI_MASTER
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'almoxarifado',
acao: 'configurar'
});
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) throw new Error('Usuário não autenticado');
// Buscar configuração existente
let config = await ctx.db
.query('configuracoesAlmoxarifado')
.filter((q) => q.eq(q.field('ativo'), true))
.first();
const dadosAnteriores = config ? { ...config } : undefined;
if (config) {
// Desativar configuração antiga
await ctx.db.patch(config._id, { ativo: false });
// Criar nova configuração
const dadosNovos = {
...config,
...args,
ativo: true,
atualizadoPor: usuario._id,
atualizadoEm: Date.now()
};
const novaConfigId = await ctx.db.insert('configuracoesAlmoxarifado', dadosNovos);
// Registrar histórico
if (usuario) {
await ctx.db.insert('historicoAlteracoes', {
tipoEntidade: 'configuracao',
entidadeId: novaConfigId,
acao: 'edicao',
usuarioId: usuario._id,
dadosAnteriores: dadosAnteriores ? JSON.stringify(dadosAnteriores) : undefined,
dadosNovos: JSON.stringify(dadosNovos),
timestamp: Date.now(),
observacoes: 'Atualização de configurações do almoxarifado'
});
}
return novaConfigId;
} else {
// Criar primeira configuração
const dadosNovos = {
estoqueMinimoPadrao: args.estoqueMinimoPadrao ?? 10,
diasAntecedenciaAlerta: args.diasAntecedenciaAlerta ?? 7,
permitirEstoqueNegativo: args.permitirEstoqueNegativo ?? false,
requerAprovacaoRequisicao: args.requerAprovacaoRequisicao ?? true,
rolesAprovacao: args.rolesAprovacao ?? [],
emailAlertasAtivo: args.emailAlertasAtivo ?? false,
emailsDestinatarios: args.emailsDestinatarios ?? [],
periodicidadeInventario: args.periodicidadeInventario ?? 30,
ultimoInventario: args.ultimoInventario,
ativo: true,
atualizadoPor: usuario._id,
atualizadoEm: Date.now()
};
const novaConfigId = await ctx.db.insert('configuracoesAlmoxarifado', dadosNovos);
// Registrar histórico
if (usuario) {
await ctx.db.insert('historicoAlteracoes', {
tipoEntidade: 'configuracao',
entidadeId: novaConfigId,
acao: 'criacao',
usuarioId: usuario._id,
dadosAnteriores: undefined,
dadosNovos: JSON.stringify(dadosNovos),
timestamp: Date.now(),
observacoes: 'Criação de configurações do almoxarifado'
});
}
return novaConfigId;
}
}
});
// ========== INTERNAL QUERIES ==========
export const obterConfiguracaoInterno = internalQuery({
args: {},
handler: async (ctx) => {
const config = await ctx.db
.query('configuracoesAlmoxarifado')
.filter((q) => q.eq(q.field('ativo'), true))
.first();
if (!config) {
return {
estoqueMinimoPadrao: 10,
diasAntecedenciaAlerta: 7,
permitirEstoqueNegativo: false,
requerAprovacaoRequisicao: true,
rolesAprovacao: [],
emailAlertasAtivo: false,
emailsDestinatarios: [],
periodicidadeInventario: 30,
ultimoInventario: undefined,
ativo: true
};
}
return config;
}
});

View File

@@ -58,4 +58,12 @@ crons.interval(
{}
);
// Verificar alertas de estoque do almoxarifado diariamente
crons.interval(
'verificar-alertas-almoxarifado',
{ hours: 24 },
internal.almoxarifado.verificarAlertasAutomatico,
{}
);
export default crons;

View File

@@ -735,6 +735,49 @@ const PERMISSOES_BASE = {
recurso: 'config',
acao: 'gerenciar_compras',
descricao: 'Gerenciar configurações de compras'
},
// Almoxarifado
{
nome: 'almoxarifado.listar',
recurso: 'almoxarifado',
acao: 'listar',
descricao: 'Listar materiais e movimentações'
},
{
nome: 'almoxarifado.criar_material',
recurso: 'almoxarifado',
acao: 'criar_material',
descricao: 'Cadastrar novos materiais'
},
{
nome: 'almoxarifado.editar_material',
recurso: 'almoxarifado',
acao: 'editar_material',
descricao: 'Editar materiais existentes'
},
{
nome: 'almoxarifado.registrar_movimentacao',
recurso: 'almoxarifado',
acao: 'registrar_movimentacao',
descricao: 'Registrar entradas e saídas'
},
{
nome: 'almoxarifado.ajustar_estoque',
recurso: 'almoxarifado',
acao: 'ajustar_estoque',
descricao: 'Realizar ajustes manuais de estoque'
},
{
nome: 'almoxarifado.aprovar_requisicao',
recurso: 'almoxarifado',
acao: 'aprovar_requisicao',
descricao: 'Aprovar requisições de material'
},
{
nome: 'almoxarifado.configurar',
recurso: 'almoxarifado',
acao: 'configurar',
descricao: 'Configurar sistema de almoxarifado (apenas TI)'
}
]
} as const;

View File

@@ -22,6 +22,7 @@ import { systemTables } from './tables/system';
import { ticketsTables } from './tables/tickets';
import { timesTables } from './tables/times';
import { lgpdTables } from './tables/lgpdTables';
import { almoxarifadoTables } from './tables/almoxarifado';
export default defineSchema({
...setoresTables,
@@ -46,5 +47,6 @@ export default defineSchema({
...planejamentosTables,
...objetosTables,
...atasTables,
...lgpdTables
...lgpdTables,
...almoxarifadoTables
});

View File

@@ -0,0 +1,149 @@
import { defineTable } from 'convex/server';
import { type Infer, v } from 'convex/values';
export const movimentacaoTipo = v.union(
v.literal('entrada'),
v.literal('saida'),
v.literal('ajuste'),
v.literal('transferencia')
);
export type MovimentacaoTipo = Infer<typeof movimentacaoTipo>;
export const requisicaoStatus = v.union(
v.literal('pendente'),
v.literal('aprovada'),
v.literal('rejeitada'),
v.literal('atendida'),
v.literal('cancelada')
);
export type RequisicaoStatus = Infer<typeof requisicaoStatus>;
export const alertaTipo = v.union(
v.literal('estoque_minimo'),
v.literal('estoque_zerado'),
v.literal('reposicao_necessaria')
);
export type AlertaTipo = Infer<typeof alertaTipo>;
export const alertaStatus = v.union(
v.literal('ativo'),
v.literal('resolvido'),
v.literal('ignorado')
);
export type AlertaStatus = Infer<typeof alertaStatus>;
export const almoxarifadoTables = {
materiais: defineTable({
codigo: v.string(),
nome: v.string(),
descricao: v.optional(v.string()),
categoria: v.string(),
unidadeMedida: v.string(),
estoqueMinimo: v.number(),
estoqueMaximo: v.optional(v.number()),
estoqueAtual: v.number(),
localizacao: v.optional(v.string()),
fornecedor: v.optional(v.string()),
ativo: v.boolean(),
criadoPor: v.id('usuarios'),
criadoEm: v.number(),
atualizadoEm: v.number()
})
.index('by_codigo', ['codigo'])
.index('by_categoria', ['categoria'])
.index('by_ativo', ['ativo'])
.index('by_estoqueAtual', ['estoqueAtual']),
movimentacoesEstoque: defineTable({
materialId: v.id('materiais'),
tipo: movimentacaoTipo,
quantidade: v.number(),
quantidadeAnterior: v.number(),
quantidadeNova: v.number(),
motivo: v.string(),
documento: v.optional(v.string()),
funcionarioId: v.optional(v.id('funcionarios')),
setorId: v.optional(v.id('setores')),
usuarioId: v.id('usuarios'),
data: v.number(),
observacoes: v.optional(v.string())
})
.index('by_materialId', ['materialId'])
.index('by_tipo', ['tipo'])
.index('by_data', ['data'])
.index('by_funcionarioId', ['funcionarioId'])
.index('by_usuarioId', ['usuarioId']),
requisicoesMaterial: defineTable({
numero: v.string(),
solicitanteId: v.id('funcionarios'),
setorId: v.id('setores'),
status: requisicaoStatus,
aprovadoPor: v.optional(v.id('funcionarios')),
dataAprovacao: v.optional(v.number()),
observacoes: v.optional(v.string()),
criadoEm: v.number(),
atualizadoEm: v.number()
})
.index('by_status', ['status'])
.index('by_solicitanteId', ['solicitanteId'])
.index('by_setorId', ['setorId'])
.index('by_numero', ['numero']),
requisicaoItens: defineTable({
requisicaoId: v.id('requisicoesMaterial'),
materialId: v.id('materiais'),
quantidadeSolicitada: v.number(),
quantidadeAtendida: v.optional(v.number()),
observacoes: v.optional(v.string())
})
.index('by_requisicaoId', ['requisicaoId'])
.index('by_materialId', ['materialId']),
historicoAlteracoes: defineTable({
tipoEntidade: v.string(),
entidadeId: v.string(),
acao: v.string(),
usuarioId: v.id('usuarios'),
dadosAnteriores: v.optional(v.string()),
dadosNovos: v.optional(v.string()),
timestamp: v.number(),
ipAddress: v.optional(v.string()),
observacoes: v.optional(v.string())
})
.index('by_tipoEntidade', ['tipoEntidade'])
.index('by_entidadeId', ['entidadeId'])
.index('by_usuarioId', ['usuarioId'])
.index('by_timestamp', ['timestamp']),
alertasEstoque: defineTable({
materialId: v.id('materiais'),
tipo: alertaTipo,
quantidadeAtual: v.number(),
quantidadeMinima: v.number(),
status: alertaStatus,
criadoEm: v.number(),
resolvidoEm: v.optional(v.number()),
resolvidoPor: v.optional(v.id('usuarios'))
})
.index('by_materialId', ['materialId'])
.index('by_status', ['status'])
.index('by_tipo', ['tipo']),
configuracoesAlmoxarifado: defineTable({
estoqueMinimoPadrao: v.number(),
diasAntecedenciaAlerta: v.number(),
permitirEstoqueNegativo: v.boolean(),
requerAprovacaoRequisicao: v.boolean(),
rolesAprovacao: v.array(v.string()),
emailAlertasAtivo: v.boolean(),
emailsDestinatarios: v.array(v.string()),
periodicidadeInventario: v.number(),
ultimoInventario: v.optional(v.number()),
ativo: v.boolean(),
atualizadoPor: v.id('usuarios'),
atualizadoEm: v.number()
})
};