feat: implement requisition approval and rejection functionality in 'Almoxarifado', including stock verification, modal confirmations, and improved error handling for better inventory management

This commit is contained in:
2025-12-22 13:47:05 -03:00
parent b1db926ab4
commit 7ccca5c233
8 changed files with 783 additions and 23 deletions

View File

@@ -72,6 +72,11 @@
}
function resizeImage(dataUrl: string, maxWidth: number, maxHeight: number): Promise<string> {
// Verificar se estamos no browser (não durante SSR)
if (typeof window === 'undefined') {
return Promise.reject(new Error('resizeImage não pode ser executada durante SSR'));
}
return new Promise((resolve, reject) => {
const img = new window.Image();
img.onload = () => {

View File

@@ -1,6 +1,8 @@
<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 { toast } from 'svelte-sonner';
import { Check, Zap, Clock, Info, AlertTriangle, Calendar, X, Plus, ChevronLeft, ChevronRight, Trash2, CheckCircle } from 'lucide-svelte';
interface Props {

View File

@@ -7,11 +7,22 @@ export const load = async ({ locals, url }) => {
throw redirect(302, '/login?redirect=' + url.pathname);
}
try {
const client = createConvexHttpClient({ token: locals.token });
const currentUser = await client.query(api.auth.getCurrentUser);
if (!currentUser) {
throw redirect(302, '/login?redirect=' + url.pathname);
}
return { currentUser };
} catch (error) {
// Se for um redirect, re-lançar
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
throw error;
}
// Para outros erros, lançar novamente para ser capturado pelo handleError
throw error;
}
};

View File

@@ -99,6 +99,12 @@
* para evitar problemas de CORS.
*/
async function carregarImagemDeUrl(url: string): Promise<string | null> {
// Verificar se estamos no browser (não durante SSR)
if (typeof window === 'undefined') {
console.warn('carregarImagemDeUrl chamada durante SSR, retornando null');
return null;
}
try {
const appOrigin = window.location.origin;
let urlOrigin: string;

View File

@@ -3,7 +3,7 @@
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, Filter, User, Building2, Calendar, FileText, Search, Hash } from 'lucide-svelte';
import { ClipboardList, Plus, CheckCircle, XCircle, Package, Filter, User, Building2, Calendar, FileText, Search, Hash, Eye, AlertTriangle } from 'lucide-svelte';
import ErrorModal from '$lib/components/ErrorModal.svelte';
const client = useConvexClient();
@@ -172,27 +172,177 @@
}
}
async function aprovarRequisicao(id: Id<'requisicoesMaterial'>) {
let requisicaoParaAprovar = $state<Id<'requisicoesMaterial'> | null>(null);
let showModalAprovar = $state(false);
let problemasEstoqueAprovar = $state<Array<{
materialNome: string;
quantidadeSolicitada: number;
estoqueAtual: number;
falta: number;
}>>([]);
let processandoAprovar = $state(false);
function abrirModalAprovar(id: Id<'requisicoesMaterial'>) {
requisicaoParaAprovar = id;
problemasEstoqueAprovar = [];
showModalAprovar = true;
processandoAprovar = false;
// Verificar estoque quando abrir o modal
client.query(api.almoxarifado.verificarEstoqueRequisicao, { id }).then((verificacao) => {
if (!verificacao.temEstoqueSuficiente) {
problemasEstoqueAprovar = verificacao.problemasEstoque;
}
}).catch(() => {
// Ignorar erros na verificação prévia
});
}
function fecharModalAprovar() {
showModalAprovar = false;
requisicaoParaAprovar = null;
problemasEstoqueAprovar = [];
processandoAprovar = false;
}
async function confirmarAprovar() {
if (!requisicaoParaAprovar) return;
try {
await client.mutation(api.almoxarifado.aprovarRequisicao, { id });
processandoAprovar = true;
await client.mutation(api.almoxarifado.aprovarRequisicao, { id: requisicaoParaAprovar });
mostrarMensagem('success', 'Requisição aprovada com sucesso!');
fecharModalAprovar();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao aprovar requisição';
mostrarMensagem('error', message);
} finally {
processandoAprovar = false;
}
}
async function atenderRequisicao(id: Id<'requisicoesMaterial'>) {
if (!confirm('Tem certeza que deseja atender esta requisição? Isso registrará as saídas de material.')) {
let requisicaoParaVisualizar = $state<Id<'requisicoesMaterial'> | null>(null);
let showModalVisualizar = $state(false);
let requisicaoDetalhes = $state<any>(null);
let carregandoDetalhes = $state(false);
let erroDetalhes = $state<string | null>(null);
// Usar $effect para buscar dados quando requisicaoParaVisualizar muda
$effect(() => {
const id = requisicaoParaVisualizar;
if (!id) {
requisicaoDetalhes = null;
carregandoDetalhes = false;
erroDetalhes = null;
return;
}
carregandoDetalhes = true;
erroDetalhes = null;
requisicaoDetalhes = null;
client.query(api.almoxarifado.obterRequisicao, { id })
.then((data) => {
// Verificar se o ID ainda é o mesmo (evitar race conditions)
if (requisicaoParaVisualizar === id) {
requisicaoDetalhes = data;
carregandoDetalhes = false;
erroDetalhes = null;
}
})
.catch((error) => {
// Verificar se o ID ainda é o mesmo (evitar race conditions)
if (requisicaoParaVisualizar === id) {
erroDetalhes = error?.message || String(error);
carregandoDetalhes = false;
requisicaoDetalhes = null;
}
});
});
function abrirModalVisualizar(id: Id<'requisicoesMaterial'>) {
requisicaoParaVisualizar = id;
showModalVisualizar = true;
}
function fecharModalVisualizar() {
showModalVisualizar = false;
requisicaoParaVisualizar = null;
}
let requisicaoParaReprovar = $state<Id<'requisicoesMaterial'> | null>(null);
let motivoReprovacao = $state('');
let showModalReprovar = $state(false);
let processandoReprovar = $state(false);
function abrirModalReprovar(id: Id<'requisicoesMaterial'>) {
requisicaoParaReprovar = id;
motivoReprovacao = '';
showModalReprovar = true;
processandoReprovar = false;
}
function fecharModalReprovar() {
showModalReprovar = false;
requisicaoParaReprovar = null;
motivoReprovacao = '';
processandoReprovar = false;
}
async function confirmarReprovar() {
if (!requisicaoParaReprovar) return;
if (!motivoReprovacao.trim()) {
mostrarMensagem('error', 'É necessário informar o motivo da reprovação');
return;
}
try {
await client.mutation(api.almoxarifado.atenderRequisicao, { id });
processandoReprovar = true;
await client.mutation(api.almoxarifado.reprovarRequisicao, {
id: requisicaoParaReprovar,
motivoReprovacao: motivoReprovacao.trim()
});
mostrarMensagem('success', 'Requisição reprovada com sucesso!');
fecharModalReprovar();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao reprovar requisição';
mostrarMensagem('error', message);
} finally {
processandoReprovar = false;
}
}
let requisicaoParaAtender = $state<Id<'requisicoesMaterial'> | null>(null);
let showModalAtender = $state(false);
let processandoAtender = $state(false);
function abrirModalAtender(id: Id<'requisicoesMaterial'>) {
requisicaoParaAtender = id;
showModalAtender = true;
processandoAtender = false;
}
function fecharModalAtender() {
showModalAtender = false;
requisicaoParaAtender = null;
processandoAtender = false;
}
async function confirmarAtender() {
if (!requisicaoParaAtender) return;
try {
processandoAtender = true;
await client.mutation(api.almoxarifado.atenderRequisicao, { id: requisicaoParaAtender });
mostrarMensagem('success', 'Requisição atendida com sucesso!');
fecharModalAtender();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao atender requisição';
mostrarMensagem('error', message);
} finally {
processandoAtender = false;
}
}
@@ -426,28 +576,50 @@
<span class="badge badge-ghost">{setor?.nome || 'Carregando...'}</span>
</td>
<td>
<div class="flex flex-col gap-1">
<span class="badge {getStatusBadge(requisicao.status)} badge-lg">
{getStatusLabel(requisicao.status)}
</span>
{#if requisicao.status === 'rejeitada' && requisicao.motivoReprovacao}
<div class="text-xs text-error mt-1 max-w-xs truncate" title={requisicao.motivoReprovacao}>
<strong>Motivo:</strong> {requisicao.motivoReprovacao}
</div>
{/if}
</div>
</td>
<td>
<span class="text-sm">{new Date(requisicao.criadoEm).toLocaleDateString('pt-BR')}</span>
</td>
<td>
<div class="flex gap-2">
<button
class="btn btn-sm btn-ghost transition-all"
onclick={() => abrirModalVisualizar(requisicao._id)}
title="Visualizar requisição"
>
<Eye class="h-4 w-4" />
</button>
{#if requisicao.status === 'pendente'}
<button
class="btn btn-sm btn-success transition-all"
onclick={() => aprovarRequisicao(requisicao._id)}
onclick={() => abrirModalAprovar(requisicao._id)}
title="Aprovar requisição"
>
<CheckCircle class="h-4 w-4" />
Aprovar
</button>
<button
class="btn btn-sm btn-error transition-all"
onclick={() => abrirModalReprovar(requisicao._id)}
title="Reprovar requisição"
>
<XCircle class="h-4 w-4" />
Reprovar
</button>
{:else if requisicao.status === 'aprovada'}
<button
class="btn btn-sm btn-primary transition-all"
onclick={() => atenderRequisicao(requisicao._id)}
onclick={() => abrirModalAtender(requisicao._id)}
title="Atender requisição"
>
<Package class="h-4 w-4" />
@@ -695,6 +867,423 @@
</div>
</div>
{/if}
<!-- Modal Aprovar Requisição -->
{#if showModalAprovar}
<div class="modal modal-open backdrop-blur-sm">
<div class="modal-box max-w-2xl border border-base-300 shadow-2xl">
<div class="mb-6 flex items-center gap-4 border-b-2 border-success/20 pb-4">
<div class="rounded-2xl bg-success/20 p-3">
<CheckCircle class="h-8 w-8 text-success" strokeWidth={2.5} />
</div>
<div class="flex-1">
<h3 class="text-2xl font-bold text-base-content">Aprovar Requisição</h3>
<p class="text-base-content/70 mt-1">Confirmar aprovação da requisição</p>
</div>
<button class="btn btn-ghost btn-sm" onclick={fecharModalAprovar} disabled={processandoAprovar}>
<XCircle class="h-5 w-5" />
</button>
</div>
{#if problemasEstoqueAprovar.length > 0}
<div class="mb-6">
<div class="alert alert-warning border-warning/30 bg-warning/10">
<AlertTriangle class="h-5 w-5 text-warning" />
<div>
<h4 class="font-bold text-base-content">Estoque Insuficiente</h4>
<p class="text-sm text-base-content/80 mt-1">
A requisição contém materiais com estoque insuficiente:
</p>
<ul class="list-disc list-inside mt-2 text-sm text-base-content/80">
{#each problemasEstoqueAprovar as problema}
<li>
{problema.materialNome}: solicitado {problema.quantidadeSolicitada}, disponível {problema.estoqueAtual} (faltam {problema.falta})
</li>
{/each}
</ul>
<p class="text-sm text-base-content/80 mt-2">
A aprovação permitirá atender apenas a quantidade disponível em estoque.
</p>
</div>
</div>
</div>
{:else}
<div class="mb-6">
<div class="alert alert-info border-info/30 bg-info/10">
<CheckCircle class="h-5 w-5 text-info" />
<div>
<h4 class="font-bold text-base-content">Confirmar Aprovação</h4>
<p class="text-sm text-base-content/80 mt-1">
A requisição será aprovada e ficará disponível para atendimento.
</p>
</div>
</div>
</div>
{/if}
<div class="modal-action gap-3 border-t-2 border-base-300 pt-6">
<button
class="btn btn-ghost btn-lg min-w-[140px]"
onclick={fecharModalAprovar}
disabled={processandoAprovar}
>
Cancelar
</button>
<button
class="btn btn-success btn-lg min-w-[200px] shadow-lg hover:shadow-xl"
onclick={confirmarAprovar}
disabled={processandoAprovar}
>
{#if processandoAprovar}
<span class="loading loading-spinner loading-sm"></span>
Aprovando...
{:else}
<CheckCircle class="h-5 w-5" />
Confirmar Aprovação
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Modal Reprovar Requisição -->
{#if showModalReprovar}
<div class="modal modal-open backdrop-blur-sm">
<div class="modal-box max-w-2xl border border-base-300 shadow-2xl">
<div class="mb-6 flex items-center gap-4 border-b-2 border-error/20 pb-4">
<div class="rounded-2xl bg-error/20 p-3">
<XCircle class="h-8 w-8 text-error" strokeWidth={2.5} />
</div>
<div class="flex-1">
<h3 class="text-2xl font-bold text-base-content">Reprovar Requisição</h3>
<p class="text-base-content/70 mt-1">Informe o motivo da reprovação</p>
</div>
<button class="btn btn-ghost btn-sm" onclick={fecharModalReprovar} disabled={processandoReprovar}>
<XCircle class="h-5 w-5" />
</button>
</div>
<div class="mb-6">
<div class="alert alert-warning border-warning/30 bg-warning/10 mb-4">
<AlertTriangle class="h-5 w-5 text-warning" />
<div>
<h4 class="font-bold text-base-content">Confirmar Reprovação</h4>
<p class="text-sm text-base-content/80 mt-1">
O motivo informado será registrado e poderá ser visualizado pelo solicitante da requisição.
</p>
</div>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-semibold flex items-center gap-2">
<FileText class="h-4 w-4" />
Motivo da Reprovação <span class="text-error">*</span>
</span>
</label>
<textarea
class="textarea textarea-bordered w-full focus:textarea-error transition-colors h-32"
placeholder="Descreva o motivo da reprovação desta requisição..."
bind:value={motivoReprovacao}
rows="5"
disabled={processandoReprovar}
></textarea>
<label class="label pt-1">
<span class="label-text-alt text-base-content/60">Este motivo será registrado e poderá ser visualizado pelo solicitante</span>
</label>
</div>
</div>
<div class="modal-action gap-3 border-t-2 border-base-300 pt-6">
<button
class="btn btn-ghost btn-lg min-w-[140px]"
onclick={fecharModalReprovar}
disabled={processandoReprovar}
>
Cancelar
</button>
<button
class="btn btn-error btn-lg min-w-[200px] shadow-lg hover:shadow-xl"
onclick={confirmarReprovar}
disabled={!motivoReprovacao.trim() || processandoReprovar}
>
{#if processandoReprovar}
<span class="loading loading-spinner loading-sm"></span>
Reprovando...
{:else}
<XCircle class="h-5 w-5" />
Confirmar Reprovação
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Modal Visualizar Requisição -->
{#if showModalVisualizar}
<div class="modal modal-open backdrop-blur-sm">
<div class="modal-box max-w-4xl border border-base-300 shadow-2xl">
<div class="mb-6 flex items-center gap-4 border-b-2 border-primary/20 pb-4">
<div class="rounded-2xl bg-primary/20 p-3">
<Eye class="h-8 w-8 text-primary" strokeWidth={2.5} />
</div>
<div class="flex-1">
<h3 class="text-2xl font-bold text-base-content">Detalhes da Requisição</h3>
{#if carregandoDetalhes}
<p class="text-base-content/70 mt-1">Carregando...</p>
{:else if erroDetalhes}
<p class="text-base-content/70 mt-1 text-error">Erro: {erroDetalhes}</p>
{:else if requisicaoDetalhes}
<p class="text-base-content/70 mt-1">Número: <span class="font-mono font-bold text-primary">{requisicaoDetalhes.numero}</span></p>
{:else}
<p class="text-base-content/70 mt-1">Carregando...</p>
{/if}
</div>
<button class="btn btn-ghost btn-sm" onclick={fecharModalVisualizar}>
<XCircle class="h-5 w-5" />
</button>
</div>
{#if carregandoDetalhes}
<div class="flex items-center justify-center py-16">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if erroDetalhes}
<div class="alert alert-error">
<AlertTriangle class="h-5 w-5" />
<div>
<h4 class="font-bold">Erro ao carregar detalhes</h4>
<p class="text-sm">{erroDetalhes}</p>
</div>
</div>
{:else if requisicaoDetalhes}
{@const solicitanteDetalhes = funcionariosQuery?.data?.find(f => f._id === requisicaoDetalhes.solicitanteId)}
{@const setorDetalhes = setoresQuery?.data?.find(s => s._id === requisicaoDetalhes.setorId)}
<!-- Informações Básicas -->
<div class="mb-6">
<h4 class="mb-4 text-lg font-bold flex items-center gap-2">
<FileText class="h-5 w-5" />
Informações Básicas
</h4>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="card bg-base-200">
<div class="card-body p-4">
<div class="text-sm text-base-content/70">Solicitante</div>
<div class="font-semibold">{solicitanteDetalhes?.nome || 'Carregando...'}</div>
</div>
</div>
<div class="card bg-base-200">
<div class="card-body p-4">
<div class="text-sm text-base-content/70">Setor</div>
<div class="font-semibold">{setorDetalhes?.nome || 'Carregando...'}</div>
</div>
</div>
<div class="card bg-base-200">
<div class="card-body p-4">
<div class="text-sm text-base-content/70">Status</div>
<span class="badge {getStatusBadge(requisicaoDetalhes.status)} badge-lg">
{getStatusLabel(requisicaoDetalhes.status)}
</span>
</div>
</div>
<div class="card bg-base-200">
<div class="card-body p-4">
<div class="text-sm text-base-content/70">Data de Criação</div>
<div class="font-semibold">{new Date(requisicaoDetalhes.criadoEm).toLocaleString('pt-BR')}</div>
</div>
</div>
{#if requisicaoDetalhes.aprovadoPor}
{@const aprovador = funcionariosQuery.data?.find(f => f._id === requisicaoDetalhes.aprovadoPor)}
<div class="card bg-base-200">
<div class="card-body p-4">
<div class="text-sm text-base-content/70">Aprovado por</div>
<div class="font-semibold">{aprovador?.nome || 'Carregando...'}</div>
{#if requisicaoDetalhes.dataAprovacao}
<div class="text-xs text-base-content/60 mt-1">
{new Date(requisicaoDetalhes.dataAprovacao).toLocaleString('pt-BR')}
</div>
{/if}
</div>
</div>
{/if}
{#if requisicaoDetalhes.reprovadoPor}
{@const reprovador = funcionariosQuery.data?.find(f => f._id === requisicaoDetalhes.reprovadoPor)}
<div class="card bg-base-200">
<div class="card-body p-4">
<div class="text-sm text-base-content/70">Reprovado por</div>
<div class="font-semibold">{reprovador?.nome || 'Carregando...'}</div>
{#if requisicaoDetalhes.dataReprovacao}
<div class="text-xs text-base-content/60 mt-1">
{new Date(requisicaoDetalhes.dataReprovacao).toLocaleString('pt-BR')}
</div>
{/if}
{#if requisicaoDetalhes.motivoReprovacao}
<div class="mt-2 text-sm">
<div class="text-xs text-base-content/70">Motivo:</div>
<div class="text-error">{requisicaoDetalhes.motivoReprovacao}</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
<!-- Itens da Requisição -->
<div class="mb-6">
<h4 class="mb-4 text-lg font-bold flex items-center gap-2">
<Package class="h-5 w-5" />
Itens da Requisição
</h4>
{#if requisicaoDetalhes.itens && requisicaoDetalhes.itens.length > 0}
<div class="overflow-x-auto rounded-lg border border-base-300">
<table class="table table-zebra">
<thead>
<tr class="bg-base-200">
<th class="font-bold text-base-content">Material</th>
<th class="font-bold text-base-content">Quantidade Solicitada</th>
<th class="font-bold text-base-content">Quantidade Atendida</th>
<th class="font-bold text-base-content">Observações</th>
</tr>
</thead>
<tbody>
{#each requisicaoDetalhes.itens as item}
{@const material = materiaisQuery.data?.find(m => m._id === item.materialId)}
<tr class="hover:bg-base-200/50 transition-colors">
<td>
<div class="font-medium">{material?.nome || 'Carregando...'}</div>
{#if material?.codigo}
<div class="text-xs text-base-content/50 font-mono">{material.codigo}</div>
{/if}
</td>
<td>
<span class="font-semibold">{item.quantidadeSolicitada}</span>
<span class="text-sm text-base-content/60 ml-1">{material?.unidadeMedida || ''}</span>
</td>
<td>
{#if item.quantidadeAtendida !== undefined}
<span class="font-semibold">{item.quantidadeAtendida}</span>
<span class="text-sm text-base-content/60 ml-1">{material?.unidadeMedida || ''}</span>
{:else}
<span class="text-base-content/50">-</span>
{/if}
</td>
<td>
{#if item.observacoes}
<div class="text-sm">{item.observacoes}</div>
{:else}
<span class="text-base-content/50">-</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="alert alert-info">
<Package class="h-5 w-5" />
<span>Nenhum item encontrado nesta requisição</span>
</div>
{/if}
</div>
<!-- Observações Gerais -->
{#if requisicaoDetalhes.observacoes}
<div class="mb-6">
<h4 class="mb-4 text-lg font-bold flex items-center gap-2">
<FileText class="h-5 w-5" />
Observações Gerais
</h4>
<div class="card bg-base-200">
<div class="card-body p-4">
<p class="text-sm whitespace-pre-wrap">{requisicaoDetalhes.observacoes}</p>
</div>
</div>
</div>
{/if}
<div class="modal-action">
<button class="btn btn-ghost" onclick={fecharModalVisualizar}>
Fechar
</button>
</div>
{:else}
<div class="flex items-center justify-center py-16">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div class="modal-action">
<button class="btn btn-ghost" onclick={fecharModalVisualizar}>
Fechar
</button>
</div>
{/if}
</div>
</div>
{/if}
<!-- Modal Atender Requisição -->
{#if showModalAtender}
<div class="modal modal-open backdrop-blur-sm">
<div class="modal-box max-w-2xl border border-base-300 shadow-2xl">
<div class="mb-6 flex items-center gap-4 border-b-2 border-primary/20 pb-4">
<div class="rounded-2xl bg-primary/20 p-3">
<Package class="h-8 w-8 text-primary" strokeWidth={2.5} />
</div>
<div class="flex-1">
<h3 class="text-2xl font-bold text-base-content">Atender Requisição</h3>
<p class="text-base-content/70 mt-1">Registrar saídas de material</p>
</div>
<button class="btn btn-ghost btn-sm" onclick={fecharModalAtender} disabled={processandoAtender}>
<XCircle class="h-5 w-5" />
</button>
</div>
<div class="mb-6">
<div class="alert alert-info border-info/30 bg-info/10">
<FileText class="h-5 w-5 text-info" />
<div>
<h4 class="font-bold text-base-content">Confirmar Atendimento</h4>
<p class="text-sm text-base-content/80 mt-1">
Ao confirmar, serão registradas as saídas de material do estoque conforme os itens da requisição.
</p>
</div>
</div>
</div>
<div class="mb-6">
<p class="text-base-content">
Tem certeza que deseja atender esta requisição? Isso registrará as saídas de material no estoque.
</p>
</div>
<div class="modal-action gap-3 border-t-2 border-base-300 pt-6">
<button
class="btn btn-ghost btn-lg min-w-[140px]"
onclick={fecharModalAtender}
disabled={processandoAtender}
>
Cancelar
</button>
<button
class="btn btn-primary btn-lg min-w-[200px] shadow-lg hover:shadow-xl"
onclick={confirmarAtender}
disabled={processandoAtender}
>
{#if processandoAtender}
<span class="loading loading-spinner loading-sm"></span>
Atendendo...
{:else}
<Package class="h-5 w-5" />
Confirmar Atendimento
{/if}
</button>
</div>
</div>
</div>
{/if}
</main>

View File

@@ -1132,6 +1132,53 @@ export const criarRequisicao = mutation({
}
});
export const verificarEstoqueRequisicao = query({
args: { id: v.id('requisicoesMaterial') },
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'almoxarifado',
acao: 'aprovar_requisicao'
});
const requisicao = await ctx.db.get(args.id);
if (!requisicao) throw new Error('Requisição não encontrada');
// Buscar itens da requisição
const itens = await ctx.db
.query('requisicaoItens')
.withIndex('by_requisicaoId', (q) => q.eq('requisicaoId', args.id))
.collect();
const problemasEstoque: Array<{
materialId: Id<'materiais'>;
materialNome: string;
quantidadeSolicitada: number;
estoqueAtual: number;
falta: number;
}> = [];
for (const item of itens) {
const material = await ctx.db.get(item.materialId);
if (!material) continue;
if (material.estoqueAtual < item.quantidadeSolicitada) {
problemasEstoque.push({
materialId: material._id,
materialNome: material.nome,
quantidadeSolicitada: item.quantidadeSolicitada,
estoqueAtual: material.estoqueAtual,
falta: item.quantidadeSolicitada - material.estoqueAtual
});
}
}
return {
temEstoqueSuficiente: problemasEstoque.length === 0,
problemasEstoque
};
}
});
export const aprovarRequisicao = mutation({
args: {
id: v.id('requisicoesMaterial'),
@@ -1149,16 +1196,55 @@ export const aprovarRequisicao = mutation({
throw new Error('Apenas requisições pendentes podem ser aprovadas');
}
// Verificar estoque antes de aprovar
const itens = await ctx.db
.query('requisicaoItens')
.withIndex('by_requisicaoId', (q) => q.eq('requisicaoId', args.id))
.collect();
const problemasEstoque: Array<{
materialNome: string;
quantidadeSolicitada: number;
estoqueAtual: number;
}> = [];
for (const item of itens) {
const material = await ctx.db.get(item.materialId);
if (!material) continue;
if (material.estoqueAtual < item.quantidadeSolicitada) {
problemasEstoque.push({
materialNome: material.nome,
quantidadeSolicitada: item.quantidadeSolicitada,
estoqueAtual: material.estoqueAtual
});
}
}
if (problemasEstoque.length > 0) {
const mensagem = `Estoque insuficiente para os seguintes materiais:\n${problemasEstoque.map(p => `- ${p.materialNome}: solicitado ${p.quantidadeSolicitada}, disponível ${p.estoqueAtual}`).join('\n')}`;
throw new Error(mensagem);
}
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) throw new Error('Usuário não autenticado');
// Buscar funcionário do usuário
const funcionario = await ctx.db
// Buscar funcionário do usuário - primeiro tenta por funcionarioId, depois por email
let funcionario;
if (usuario.funcionarioId) {
funcionario = await ctx.db.get(usuario.funcionarioId);
}
if (!funcionario) {
funcionario = await ctx.db
.query('funcionarios')
.filter((q) => q.eq(q.field('email'), usuario.email))
.first();
}
if (!funcionario) throw new Error('Funcionário não encontrado para o usuário');
if (!funcionario) {
throw new Error('Funcionário não encontrado para o usuário');
}
await ctx.db.patch(args.id, {
status: 'aprovada',
@@ -1176,6 +1262,64 @@ export const aprovarRequisicao = mutation({
}
});
export const reprovarRequisicao = mutation({
args: {
id: v.id('requisicoesMaterial'),
motivoReprovacao: v.string()
},
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'almoxarifado',
acao: 'aprovar_requisicao'
});
const requisicao = await ctx.db.get(args.id);
if (!requisicao) throw new Error('Requisição não encontrada');
if (requisicao.status !== 'pendente') {
throw new Error('Apenas requisições pendentes podem ser reprovadas');
}
if (!args.motivoReprovacao.trim()) {
throw new Error('É necessário informar o motivo da reprovação');
}
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) throw new Error('Usuário não autenticado');
// Buscar funcionário do usuário - primeiro tenta por funcionarioId, depois por email
let funcionario;
if (usuario.funcionarioId) {
funcionario = await ctx.db.get(usuario.funcionarioId);
}
if (!funcionario) {
funcionario = await ctx.db
.query('funcionarios')
.filter((q) => q.eq(q.field('email'), usuario.email))
.first();
}
if (!funcionario) {
throw new Error('Funcionário não encontrado para o usuário');
}
await ctx.db.patch(args.id, {
status: 'rejeitada',
reprovadoPor: funcionario._id,
dataReprovacao: Date.now(),
motivoReprovacao: args.motivoReprovacao.trim(),
atualizadoEm: Date.now()
});
// Registrar histórico
await registrarHistorico(ctx, 'requisicao', args.id.toString(), 'edicao', requisicao, {
status: 'rejeitada',
reprovadoPor: funcionario._id,
motivoReprovacao: args.motivoReprovacao.trim()
});
}
});
export const atenderRequisicao = mutation({
args: {
id: v.id('requisicoesMaterial')

View File

@@ -38,7 +38,7 @@ export const createAuth = (
logger: {
disabled: optionsOnly
},
trustedOrigins: ['https://vite.kilder.dev'],
trustedOrigins: ['https://vite.kilder.dev', 'http://localhost:5173', 'http://127.0.0.1:5173'],
baseURL: siteUrl,
database: authComponent.adapter(ctx),
// Configure simple, non-verified email/password to get started

View File

@@ -85,6 +85,9 @@ export const almoxarifadoTables = {
status: requisicaoStatus,
aprovadoPor: v.optional(v.id('funcionarios')),
dataAprovacao: v.optional(v.number()),
reprovadoPor: v.optional(v.id('funcionarios')),
dataReprovacao: v.optional(v.number()),
motivoReprovacao: v.optional(v.string()),
observacoes: v.optional(v.string()),
criadoEm: v.number(),
atualizadoEm: v.number()