feat: implement almoxarifado features including new category in recursos-humanos, configuration options in TI, and backend support for inventory management, enhancing user navigation and system functionality
This commit is contained in:
89
apps/web/src/lib/components/almoxarifado/AlertaCard.svelte
Normal file
89
apps/web/src/lib/components/almoxarifado/AlertaCard.svelte
Normal 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>
|
||||
|
||||
|
||||
51
apps/web/src/lib/components/almoxarifado/EstoqueGauge.svelte
Normal file
51
apps/web/src/lib/components/almoxarifado/EstoqueGauge.svelte
Normal 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
68
apps/web/src/lib/components/almoxarifado/MaterialCard.svelte
Normal file
68
apps/web/src/lib/components/almoxarifado/MaterialCard.svelte
Normal 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>
|
||||
|
||||
|
||||
219
apps/web/src/lib/components/almoxarifado/MovimentacaoForm.svelte
Normal file
219
apps/web/src/lib/components/almoxarifado/MovimentacaoForm.svelte
Normal 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user