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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user