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:
@@ -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 = () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,11 +7,22 @@ export const load = async ({ locals, url }) => {
|
||||
throw redirect(302, '/login?redirect=' + url.pathname);
|
||||
}
|
||||
|
||||
const client = createConvexHttpClient({ token: locals.token });
|
||||
const currentUser = await client.query(api.auth.getCurrentUser);
|
||||
try {
|
||||
const client = createConvexHttpClient({ token: locals.token });
|
||||
const currentUser = await client.query(api.auth.getCurrentUser);
|
||||
|
||||
if (!currentUser) {
|
||||
throw redirect(302, '/login?redirect=' + url.pathname);
|
||||
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;
|
||||
}
|
||||
return { currentUser };
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
<span class="badge {getStatusBadge(requisicao.status)} badge-lg">
|
||||
{getStatusLabel(requisicao.status)}
|
||||
</span>
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
.query('funcionarios')
|
||||
.filter((q) => q.eq(q.field('email'), usuario.email))
|
||||
.first();
|
||||
// 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) throw new Error('Funcionário não encontrado para o usuário');
|
||||
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: '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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user