Feat pedidos #68
@@ -108,11 +108,26 @@
|
|||||||
link: '/pedidos',
|
link: '/pedidos',
|
||||||
permission: { recurso: 'pedidos', acao: 'listar' },
|
permission: { recurso: 'pedidos', acao: 'listar' },
|
||||||
submenus: [
|
submenus: [
|
||||||
|
{
|
||||||
|
label: 'Novo Pedido',
|
||||||
|
link: '/pedidos/novo',
|
||||||
|
permission: { recurso: 'pedidos', acao: 'criar' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Planejamentos',
|
||||||
|
link: '/pedidos/planejamento',
|
||||||
|
permission: { recurso: 'pedidos', acao: 'listar' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Meus Pedidos',
|
label: 'Meus Pedidos',
|
||||||
link: '/pedidos',
|
link: '/pedidos',
|
||||||
permission: { recurso: 'pedidos', acao: 'listar' },
|
permission: { recurso: 'pedidos', acao: 'listar' },
|
||||||
excludePaths: ['/pedidos/aceite', '/pedidos/minhas-analises']
|
excludePaths: [
|
||||||
|
'/pedidos/aceite',
|
||||||
|
'/pedidos/minhas-analises',
|
||||||
|
'/pedidos/novo',
|
||||||
|
'/pedidos/planejamento'
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Pedidos para Aceite',
|
label: 'Pedidos para Aceite',
|
||||||
@@ -158,7 +173,7 @@
|
|||||||
submenus: [
|
submenus: [
|
||||||
{
|
{
|
||||||
label: 'Meus Processos',
|
label: 'Meus Processos',
|
||||||
link: '/fluxos/meus-processos',
|
link: '/fluxos',
|
||||||
permission: { recurso: 'fluxos_instancias', acao: 'listar' }
|
permission: { recurso: 'fluxos_instancias', acao: 'listar' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import { notificacoesCount } from '$lib/stores/chatStore';
|
import { notificacoesCount } from '$lib/stores/chatStore';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { ptBR } from 'date-fns/locale';
|
import { ptBR } from 'date-fns/locale';
|
||||||
@@ -18,9 +20,10 @@
|
|||||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||||
|
|
||||||
let modalOpen = $state(false);
|
let modalOpen = $state(false);
|
||||||
|
let usuarioId = $derived((currentUser?.data?._id as Id<'usuarios'> | undefined) ?? null);
|
||||||
let notificacoesFerias = $state<
|
let notificacoesFerias = $state<
|
||||||
Array<{
|
Array<{
|
||||||
_id: string;
|
_id: Id<'notificacoesFerias'>;
|
||||||
mensagem: string;
|
mensagem: string;
|
||||||
tipo: string;
|
tipo: string;
|
||||||
_creationTime: number;
|
_creationTime: number;
|
||||||
@@ -28,7 +31,7 @@
|
|||||||
>([]);
|
>([]);
|
||||||
let notificacoesAusencias = $state<
|
let notificacoesAusencias = $state<
|
||||||
Array<{
|
Array<{
|
||||||
_id: string;
|
_id: Id<'notificacoesAusencias'>;
|
||||||
mensagem: string;
|
mensagem: string;
|
||||||
tipo: string;
|
tipo: string;
|
||||||
_creationTime: number;
|
_creationTime: number;
|
||||||
@@ -47,43 +50,40 @@
|
|||||||
// Separar notificações lidas e não lidas
|
// Separar notificações lidas e não lidas
|
||||||
let notificacoesNaoLidas = $derived(todasNotificacoes.filter((n) => !n.lida));
|
let notificacoesNaoLidas = $derived(todasNotificacoes.filter((n) => !n.lida));
|
||||||
let notificacoesLidas = $derived(todasNotificacoes.filter((n) => n.lida));
|
let notificacoesLidas = $derived(todasNotificacoes.filter((n) => n.lida));
|
||||||
|
let totalCount = $derived(count + (notificacoesFerias?.length || 0));
|
||||||
|
|
||||||
// Atualizar contador no store
|
// Atualizar contador no store
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const totalNotificacoes =
|
const totalNotificacoes =
|
||||||
count + (notificacoesFerias?.length || 0) + (notificacoesAusencias?.length || 0);
|
count + (notificacoesFerias?.length || 0) + (notificacoesAusencias?.length || 0);
|
||||||
notificacoesCount.set(totalNotificacoes);
|
$notificacoesCount = totalNotificacoes;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Buscar notificações de férias
|
// Buscar notificações de férias
|
||||||
async function buscarNotificacoesFerias() {
|
async function buscarNotificacoesFerias(id: Id<'usuarios'> | null) {
|
||||||
try {
|
try {
|
||||||
const usuarioId = currentUser?.data?._id;
|
if (!id) return;
|
||||||
if (usuarioId) {
|
|
||||||
const notifsFerias = await client.query(api.ferias.obterNotificacoesNaoLidas, {
|
const notifsFerias = await client.query(api.ferias.obterNotificacoesNaoLidas, {
|
||||||
usuarioId
|
usuarioId: id
|
||||||
});
|
});
|
||||||
notificacoesFerias = notifsFerias || [];
|
notificacoesFerias = notifsFerias || [];
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Erro ao buscar notificações de férias:', e);
|
console.error('Erro ao buscar notificações de férias:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buscar notificações de ausências
|
// Buscar notificações de ausências
|
||||||
async function buscarNotificacoesAusencias() {
|
async function buscarNotificacoesAusencias(id: Id<'usuarios'> | null) {
|
||||||
try {
|
try {
|
||||||
const usuarioId = currentUser?.data?._id;
|
if (!id) return;
|
||||||
if (usuarioId) {
|
|
||||||
try {
|
try {
|
||||||
const notifsAusencias = await client.query(api.ausencias.obterNotificacoesNaoLidas, {
|
const notifsAusencias = await client.query(api.ausencias.obterNotificacoesNaoLidas, {
|
||||||
usuarioId
|
usuarioId: id
|
||||||
});
|
});
|
||||||
notificacoesAusencias = notifsAusencias || [];
|
notificacoesAusencias = notifsAusencias || [];
|
||||||
} catch (queryError: unknown) {
|
} catch (queryError: unknown) {
|
||||||
// Silenciar erros de timeout e função não encontrada
|
// Silenciar erros de timeout e função não encontrada
|
||||||
const errorMessage =
|
const errorMessage = queryError instanceof Error ? queryError.message : String(queryError);
|
||||||
queryError instanceof Error ? queryError.message : String(queryError);
|
|
||||||
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
|
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
|
||||||
const isFunctionNotFound = errorMessage.includes('Could not find public function');
|
const isFunctionNotFound = errorMessage.includes('Could not find public function');
|
||||||
|
|
||||||
@@ -92,7 +92,6 @@
|
|||||||
}
|
}
|
||||||
notificacoesAusencias = [];
|
notificacoesAusencias = [];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Erro geral - silenciar se for sobre função não encontrada ou timeout
|
// Erro geral - silenciar se for sobre função não encontrada ou timeout
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
@@ -106,13 +105,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Atualizar notificações periodicamente
|
// Atualizar notificações periodicamente
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
buscarNotificacoesFerias();
|
void buscarNotificacoesFerias(usuarioId);
|
||||||
buscarNotificacoesAusencias();
|
void buscarNotificacoesAusencias(usuarioId);
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
buscarNotificacoesFerias();
|
void buscarNotificacoesFerias(usuarioId);
|
||||||
buscarNotificacoesAusencias();
|
void buscarNotificacoesAusencias(usuarioId);
|
||||||
}, 30000); // A cada 30s
|
}, 30000); // A cada 30s
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,30 +128,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMarcarTodasLidas() {
|
|
||||||
await client.mutation(api.chat.marcarTodasNotificacoesLidas, {});
|
|
||||||
// Marcar todas as notificações de férias como lidas
|
|
||||||
for (const notif of notificacoesFerias) {
|
|
||||||
await client.mutation(api.ferias.marcarComoLida, {
|
|
||||||
notificacaoId: notif._id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Marcar todas as notificações de ausências como lidas
|
|
||||||
for (const notif of notificacoesAusencias) {
|
|
||||||
await client.mutation(api.ausencias.marcarComoLida, {
|
|
||||||
notificacaoId: notif._id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await buscarNotificacoesFerias();
|
|
||||||
await buscarNotificacoesAusencias();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLimparTodasNotificacoes() {
|
async function handleLimparTodasNotificacoes() {
|
||||||
limpandoNotificacoes = true;
|
limpandoNotificacoes = true;
|
||||||
try {
|
try {
|
||||||
await client.mutation(api.chat.limparTodasNotificacoes, {});
|
await client.mutation(api.chat.limparTodasNotificacoes, {});
|
||||||
await buscarNotificacoesFerias();
|
await buscarNotificacoesFerias(usuarioId);
|
||||||
await buscarNotificacoesAusencias();
|
await buscarNotificacoesAusencias(usuarioId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao limpar notificações:', error);
|
console.error('Erro ao limpar notificações:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -162,8 +145,8 @@
|
|||||||
limpandoNotificacoes = true;
|
limpandoNotificacoes = true;
|
||||||
try {
|
try {
|
||||||
await client.mutation(api.chat.limparNotificacoesNaoLidas, {});
|
await client.mutation(api.chat.limparNotificacoesNaoLidas, {});
|
||||||
await buscarNotificacoesFerias();
|
await buscarNotificacoesFerias(usuarioId);
|
||||||
await buscarNotificacoesAusencias();
|
await buscarNotificacoesAusencias(usuarioId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao limpar notificações não lidas:', error);
|
console.error('Erro ao limpar notificações não lidas:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -173,24 +156,24 @@
|
|||||||
|
|
||||||
async function handleClickNotificacao(notificacaoId: string) {
|
async function handleClickNotificacao(notificacaoId: string) {
|
||||||
await client.mutation(api.chat.marcarNotificacaoLida, {
|
await client.mutation(api.chat.marcarNotificacaoLida, {
|
||||||
notificacaoId: notificacaoId as any
|
notificacaoId: notificacaoId as Id<'notificacoes'>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClickNotificacaoFerias(notificacaoId: string) {
|
async function handleClickNotificacaoFerias(notificacaoId: Id<'notificacoesFerias'>) {
|
||||||
await client.mutation(api.ferias.marcarComoLida, {
|
await client.mutation(api.ferias.marcarComoLida, {
|
||||||
notificacaoId: notificacaoId
|
notificacaoId: notificacaoId
|
||||||
});
|
});
|
||||||
await buscarNotificacoesFerias();
|
await buscarNotificacoesFerias(usuarioId);
|
||||||
// Redirecionar para a página de férias
|
// Redirecionar para a página de férias
|
||||||
window.location.href = '/recursos-humanos/ferias';
|
window.location.href = '/recursos-humanos/ferias';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClickNotificacaoAusencias(notificacaoId: string) {
|
async function handleClickNotificacaoAusencias(notificacaoId: Id<'notificacoesAusencias'>) {
|
||||||
await client.mutation(api.ausencias.marcarComoLida, {
|
await client.mutation(api.ausencias.marcarComoLida, {
|
||||||
notificacaoId: notificacaoId
|
notificacaoId: notificacaoId
|
||||||
});
|
});
|
||||||
await buscarNotificacoesAusencias();
|
await buscarNotificacoesAusencias(usuarioId);
|
||||||
// Redirecionar para a página de perfil na aba de ausências
|
// Redirecionar para a página de perfil na aba de ausências
|
||||||
window.location.href = '/perfil?aba=minhas-ausencias';
|
window.location.href = '/perfil?aba=minhas-ausencias';
|
||||||
}
|
}
|
||||||
@@ -204,19 +187,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fechar popup ao clicar fora ou pressionar Escape
|
// Fechar popup ao clicar fora ou pressionar Escape
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
if (!modalOpen) return;
|
|
||||||
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (!modalOpen) return;
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (!target.closest('.notification-popup') && !target.closest('.notification-bell')) {
|
if (!target.closest('.notification-popup') && !target.closest('.notification-bell')) {
|
||||||
modalOpen = false;
|
closeModal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEscape(event: KeyboardEvent) {
|
function handleEscape(event: KeyboardEvent) {
|
||||||
|
if (!modalOpen) return;
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
modalOpen = false;
|
closeModal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,56 +213,32 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="notification-bell relative">
|
<div class="notification-bell relative">
|
||||||
<!-- Botão de Notificação ULTRA MODERNO (igual ao perfil) -->
|
<!-- Botão de Notificação (padrão do tema) -->
|
||||||
<button
|
<div class="indicator">
|
||||||
type="button"
|
{#if totalCount > 0}
|
||||||
tabindex="0"
|
<span class="indicator-item badge badge-error badge-sm">
|
||||||
class="group relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-2xl transition-all duration-300 hover:scale-105"
|
|
||||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
|
|
||||||
onclick={openModal}
|
|
||||||
aria-label="Notificações"
|
|
||||||
>
|
|
||||||
<!-- Efeito de brilho no hover -->
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-linear-to-br from-white/0 to-white/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Anel de pulso sutil -->
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 rounded-2xl"
|
|
||||||
style="animation: pulse-ring-subtle 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Glow effect quando tem notificações -->
|
|
||||||
{#if count && count > 0}
|
|
||||||
<div class="bg-error/30 absolute inset-0 animate-pulse rounded-2xl blur-lg"></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Ícone do sino PREENCHIDO moderno -->
|
|
||||||
<Bell
|
|
||||||
class="relative z-10 h-7 w-7 text-white transition-all duration-300 group-hover:scale-110"
|
|
||||||
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3)); animation: {count && count > 0
|
|
||||||
? 'bell-ring 2s ease-in-out infinite'
|
|
||||||
: 'none'};"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Badge premium MODERNO com gradiente -->
|
|
||||||
{#if count + (notificacoesFerias?.length || 0) > 0}
|
|
||||||
{@const totalCount = count + (notificacoesFerias?.length || 0)}
|
|
||||||
<span
|
|
||||||
class="absolute -top-1 -right-1 z-20 flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-black text-white shadow-xl ring-2 ring-white"
|
|
||||||
style="background: linear-gradient(135deg, #ff416c, #ff4b2b); box-shadow: 0 8px 24px -4px rgba(255, 65, 108, 0.6), 0 4px 12px -2px rgba(255, 75, 43, 0.4); animation: badge-bounce 2s ease-in-out infinite;"
|
|
||||||
>
|
|
||||||
{totalCount > 9 ? '9+' : totalCount}
|
{totalCount > 9 ? '9+' : totalCount}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabindex="0"
|
||||||
|
class="btn ring-base-200 hover:ring-primary/50 size-10 p-0 ring-2 ring-offset-2 transition-all"
|
||||||
|
onclick={openModal}
|
||||||
|
aria-label="Notificações"
|
||||||
|
aria-expanded={modalOpen}
|
||||||
|
>
|
||||||
|
<Bell
|
||||||
|
class="size-6 transition-colors {totalCount > 0 ? 'text-primary' : 'text-base-content/70'}"
|
||||||
|
style="animation: {totalCount > 0 ? 'bell-ring 2s ease-in-out infinite' : 'none'};"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Popup Flutuante de Notificações -->
|
<!-- Popup Flutuante de Notificações -->
|
||||||
{#if modalOpen}
|
{#if modalOpen}
|
||||||
<div
|
<div
|
||||||
class="notification-popup bg-base-100 border-base-300 fixed top-24 right-4 z-[100] flex max-h-[calc(100vh-7rem)] w-[calc(100vw-2rem)] max-w-2xl flex-col overflow-hidden rounded-2xl border shadow-2xl backdrop-blur-sm"
|
class="notification-popup bg-base-100 border-base-300 fixed top-24 right-4 z-100 flex max-h-[calc(100vh-7rem)] w-[calc(100vw-2rem)] max-w-2xl flex-col overflow-hidden rounded-2xl border shadow-2xl backdrop-blur-sm"
|
||||||
style="animation: slideDown 0.2s ease-out;"
|
style="animation: slideDown 0.2s ease-out;"
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -310,7 +269,7 @@
|
|||||||
Limpar todas
|
Limpar todas
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button type="button" class="btn btn-sm btn-circle btn-ghost" onclick={closeModal}>
|
<button type="button" class="btn btn-sm btn-circle" onclick={closeModal}>
|
||||||
<X class="h-5 w-5" />
|
<X class="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -439,17 +398,17 @@
|
|||||||
<!-- Notificações de Férias -->
|
<!-- Notificações de Férias -->
|
||||||
{#if notificacoesFerias.length > 0}
|
{#if notificacoesFerias.length > 0}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h4 class="mb-2 px-2 text-sm font-semibold text-purple-600">Férias</h4>
|
<h4 class="text-secondary mb-2 px-2 text-sm font-semibold">Férias</h4>
|
||||||
{#each notificacoesFerias as notificacao (notificacao._id)}
|
{#each notificacoesFerias as notificacao (notificacao._id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="hover:bg-base-200 mb-2 w-full rounded-lg border-l-4 border-purple-600 px-4 py-3 text-left transition-colors"
|
class="hover:bg-base-200 border-secondary mb-2 w-full rounded-lg border-l-4 px-4 py-3 text-left transition-colors"
|
||||||
onclick={() => handleClickNotificacaoFerias(notificacao._id)}
|
onclick={() => handleClickNotificacaoFerias(notificacao._id)}
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<!-- Ícone -->
|
<!-- Ícone -->
|
||||||
<div class="mt-1 shrink-0">
|
<div class="mt-1 shrink-0">
|
||||||
<Calendar class="h-5 w-5 text-purple-600" strokeWidth={2} />
|
<Calendar class="text-secondary h-5 w-5" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Conteúdo -->
|
<!-- Conteúdo -->
|
||||||
@@ -464,7 +423,7 @@
|
|||||||
|
|
||||||
<!-- Badge -->
|
<!-- Badge -->
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<div class="badge badge-primary badge-xs"></div>
|
<div class="badge badge-secondary badge-xs"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -475,17 +434,17 @@
|
|||||||
<!-- Notificações de Ausências -->
|
<!-- Notificações de Ausências -->
|
||||||
{#if notificacoesAusencias.length > 0}
|
{#if notificacoesAusencias.length > 0}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h4 class="mb-2 px-2 text-sm font-semibold text-orange-600">Ausências</h4>
|
<h4 class="text-warning mb-2 px-2 text-sm font-semibold">Ausências</h4>
|
||||||
{#each notificacoesAusencias as notificacao (notificacao._id)}
|
{#each notificacoesAusencias as notificacao (notificacao._id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="hover:bg-base-200 mb-2 w-full rounded-lg border-l-4 border-orange-600 px-4 py-3 text-left transition-colors"
|
class="hover:bg-base-200 border-warning mb-2 w-full rounded-lg border-l-4 px-4 py-3 text-left transition-colors"
|
||||||
onclick={() => handleClickNotificacaoAusencias(notificacao._id)}
|
onclick={() => handleClickNotificacaoAusencias(notificacao._id)}
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<!-- Ícone -->
|
<!-- Ícone -->
|
||||||
<div class="mt-1 shrink-0">
|
<div class="mt-1 shrink-0">
|
||||||
<Clock class="h-5 w-5 text-orange-600" strokeWidth={2} />
|
<Clock class="text-warning h-5 w-5" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Conteúdo -->
|
<!-- Conteúdo -->
|
||||||
@@ -539,28 +498,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@keyframes badge-bounce {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-ring-subtle {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 0.1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.3;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bell-ring {
|
@keyframes bell-ring {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
|
|||||||
29
apps/web/src/lib/components/layout/Breadcrumbs.svelte
Normal file
29
apps/web/src/lib/components/layout/Breadcrumbs.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export type BreadcrumbItem = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: BreadcrumbItem[];
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { items, class: className = '' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={['breadcrumbs mb-4 text-sm', className].filter(Boolean)}>
|
||||||
|
<ul>
|
||||||
|
{#each items as item (item.label)}
|
||||||
|
<li>
|
||||||
|
{#if item.href}
|
||||||
|
<a href={item.href} class="text-primary hover:underline">{item.label}</a>
|
||||||
|
{:else}
|
||||||
|
{item.label}
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
50
apps/web/src/lib/components/layout/PageHeader.svelte
Normal file
50
apps/web/src/lib/components/layout/PageHeader.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
|
||||||
|
icon?: Snippet;
|
||||||
|
actions?: Snippet;
|
||||||
|
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
actions,
|
||||||
|
class: className = ''
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={['mb-6', className].filter(Boolean)}>
|
||||||
|
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{#if icon}
|
||||||
|
<div class="bg-primary/10 rounded-xl p-3">
|
||||||
|
<div class="text-primary [&_svg]:h-8 [&_svg]:w-8">
|
||||||
|
{@render icon()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="text-primary text-3xl font-bold">{title}</h1>
|
||||||
|
{#if subtitle}
|
||||||
|
<p class="text-base-content/70">{subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if actions}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{@render actions()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
16
apps/web/src/lib/components/layout/PageShell.svelte
Normal file
16
apps/web/src/lib/components/layout/PageShell.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { class: className = '', children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class={['container mx-auto flex max-w-7xl flex-col px-4 py-4', className].filter(Boolean)}>
|
||||||
|
{@render children?.()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
34
apps/web/src/lib/components/ui/EmptyState.svelte
Normal file
34
apps/web/src/lib/components/ui/EmptyState.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: Snippet;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { title, description, icon, class: className = '' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
'bg-base-100 border-base-300 flex flex-col items-center justify-center rounded-lg border py-12 text-center',
|
||||||
|
className
|
||||||
|
].filter(Boolean)}
|
||||||
|
>
|
||||||
|
{#if icon}
|
||||||
|
<div class="bg-base-200 mb-4 rounded-full p-4">
|
||||||
|
<div class="text-base-content/30 [&_svg]:h-8 [&_svg]:w-8">
|
||||||
|
{@render icon()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h3 class="text-lg font-semibold">{title}</h3>
|
||||||
|
{#if description}
|
||||||
|
<p class="text-base-content/60 mt-1 max-w-sm text-sm">{description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
22
apps/web/src/lib/components/ui/GlassCard.svelte
Normal file
22
apps/web/src/lib/components/ui/GlassCard.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
bodyClass?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { class: className = '', bodyClass = '', children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
'card bg-base-100/90 border-base-300 border shadow-xl backdrop-blur-sm',
|
||||||
|
className
|
||||||
|
].filter(Boolean)}
|
||||||
|
>
|
||||||
|
<div class={['card-body', bodyClass].filter(Boolean)}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
18
apps/web/src/lib/components/ui/TableCard.svelte
Normal file
18
apps/web/src/lib/components/ui/TableCard.svelte
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import GlassCard from '$lib/components/ui/GlassCard.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
bodyClass?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { class: className = '', bodyClass = '', children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<GlassCard class={className} bodyClass={['p-0', bodyClass].filter(Boolean).join(' ')}>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
377
apps/web/src/lib/utils/pedidos/relatorioPedidosExcel.ts
Normal file
377
apps/web/src/lib/utils/pedidos/relatorioPedidosExcel.ts
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import type { FunctionReturnType } from 'convex/server';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||||
|
|
||||||
|
export type RelatorioPedidosData = FunctionReturnType<typeof api.pedidos.gerarRelatorio>;
|
||||||
|
|
||||||
|
function formatDateTime(ts: number | undefined): string {
|
||||||
|
if (!ts) return '';
|
||||||
|
return new Date(ts).toLocaleString('pt-BR');
|
||||||
|
}
|
||||||
|
|
||||||
|
function argb(hex: string): { argb: string } {
|
||||||
|
return { argb: hex.replace('#', '').toUpperCase().padStart(8, 'FF') };
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'em_rascunho':
|
||||||
|
return 'Rascunho';
|
||||||
|
case 'aguardando_aceite':
|
||||||
|
return 'Aguardando Aceite';
|
||||||
|
case 'em_analise':
|
||||||
|
return 'Em Análise';
|
||||||
|
case 'precisa_ajustes':
|
||||||
|
return 'Precisa de Ajustes';
|
||||||
|
case 'concluido':
|
||||||
|
return 'Concluído';
|
||||||
|
case 'cancelado':
|
||||||
|
return 'Cancelado';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHeaderRowStyle(row: ExcelJS.Row) {
|
||||||
|
row.height = 22;
|
||||||
|
row.eachCell((cell) => {
|
||||||
|
cell.font = { bold: true, size: 11, color: argb('#FFFFFF') };
|
||||||
|
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#2980B9') };
|
||||||
|
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
||||||
|
cell.border = {
|
||||||
|
top: { style: 'thin', color: argb('#000000') },
|
||||||
|
bottom: { style: 'thin', color: argb('#000000') },
|
||||||
|
left: { style: 'thin', color: argb('#000000') },
|
||||||
|
right: { style: 'thin', color: argb('#000000') }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZebraRowStyle(row: ExcelJS.Row, isEven: boolean) {
|
||||||
|
row.eachCell((cell) => {
|
||||||
|
cell.font = { size: 10, color: argb('#000000') };
|
||||||
|
cell.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: argb(isEven ? '#F8F9FA' : '#FFFFFF')
|
||||||
|
};
|
||||||
|
cell.border = {
|
||||||
|
top: { style: 'thin', color: argb('#E0E0E0') },
|
||||||
|
bottom: { style: 'thin', color: argb('#E0E0E0') },
|
||||||
|
left: { style: 'thin', color: argb('#E0E0E0') },
|
||||||
|
right: { style: 'thin', color: argb('#E0E0E0') }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryLoadLogoBuffer(): Promise<ArrayBuffer | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(logoGovPE);
|
||||||
|
if (response.ok) return await response.arrayBuffer();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback via canvas (mesmo padrão usado em outras telas)
|
||||||
|
try {
|
||||||
|
const logoImg = new Image();
|
||||||
|
logoImg.crossOrigin = 'anonymous';
|
||||||
|
logoImg.src = logoGovPE;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
logoImg.onload = () => resolve();
|
||||||
|
logoImg.onerror = () => reject();
|
||||||
|
setTimeout(() => reject(), 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = logoImg.width;
|
||||||
|
canvas.height = logoImg.height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.drawImage(logoImg, 0, 0);
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(b) => (b ? resolve(b) : reject(new Error('Falha ao converter imagem'))),
|
||||||
|
'image/png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return await blob.arrayBuffer();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTitleRow(
|
||||||
|
worksheet: ExcelJS.Worksheet,
|
||||||
|
title: string,
|
||||||
|
columnsCount: number,
|
||||||
|
workbook: ExcelJS.Workbook,
|
||||||
|
logoBuffer: ArrayBuffer | null
|
||||||
|
) {
|
||||||
|
// Evitar merge sobreposto quando há poucas colunas (ex.: Resumo tem 2 colunas)
|
||||||
|
if (columnsCount <= 0) return;
|
||||||
|
|
||||||
|
worksheet.getRow(1).height = 60;
|
||||||
|
|
||||||
|
// Reservar espaço para logo apenas quando há 3+ colunas (A1:B1)
|
||||||
|
if (columnsCount >= 3) {
|
||||||
|
worksheet.mergeCells(1, 1, 1, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoCell = worksheet.getCell(1, 1);
|
||||||
|
logoCell.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
if (columnsCount >= 3) {
|
||||||
|
logoCell.border = { right: { style: 'thin', color: argb('#E0E0E0') } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logoBuffer) {
|
||||||
|
type WorkbookWithImage = { addImage(image: { buffer: Uint8Array; extension: string }): number };
|
||||||
|
const workbookWithImage = workbook as unknown as WorkbookWithImage;
|
||||||
|
const logoId = workbookWithImage.addImage({
|
||||||
|
buffer: new Uint8Array(logoBuffer),
|
||||||
|
extension: 'png'
|
||||||
|
});
|
||||||
|
worksheet.addImage(logoId, { tl: { col: 0, row: 0 }, ext: { width: 140, height: 55 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Título
|
||||||
|
if (columnsCount === 1) {
|
||||||
|
const titleCell = worksheet.getCell(1, 1);
|
||||||
|
titleCell.value = title;
|
||||||
|
titleCell.font = { bold: true, size: 18, color: argb('#2980B9') };
|
||||||
|
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (columnsCount === 2) {
|
||||||
|
// Sem merge para não colidir com A1:B1
|
||||||
|
const titleCell = worksheet.getCell(1, 2);
|
||||||
|
titleCell.value = title;
|
||||||
|
titleCell.font = { bold: true, size: 18, color: argb('#2980B9') };
|
||||||
|
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3+ colunas: mescla C1 até última coluna para o título
|
||||||
|
worksheet.mergeCells(1, 3, 1, columnsCount);
|
||||||
|
const titleCell = worksheet.getCell(1, 3);
|
||||||
|
titleCell.value = title;
|
||||||
|
titleCell.font = { bold: true, size: 18, color: argb('#2980B9') };
|
||||||
|
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadExcel(buffer: ArrayBuffer, fileName: string) {
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportarRelatorioPedidosXLSX(relatorio: RelatorioPedidosData): Promise<void> {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
workbook.creator = 'SGSE - Sistema de Gerenciamento';
|
||||||
|
workbook.created = new Date();
|
||||||
|
|
||||||
|
const logoBuffer = await tryLoadLogoBuffer();
|
||||||
|
|
||||||
|
// ===== Aba: Resumo =====
|
||||||
|
{
|
||||||
|
const ws = workbook.addWorksheet('Resumo');
|
||||||
|
ws.columns = [
|
||||||
|
{ header: 'Campo', key: 'campo', width: 35 },
|
||||||
|
{ header: 'Valor', key: 'valor', width: 30 }
|
||||||
|
];
|
||||||
|
|
||||||
|
addTitleRow(ws, 'RELATÓRIO DE PEDIDOS', 2, workbook, logoBuffer);
|
||||||
|
|
||||||
|
// Cabeçalho da tabela em linha 2
|
||||||
|
const headerRow = ws.getRow(2);
|
||||||
|
headerRow.values = ['Campo', 'Valor'];
|
||||||
|
applyHeaderRowStyle(headerRow);
|
||||||
|
|
||||||
|
const rows: Array<{ campo: string; valor: string | number }> = [
|
||||||
|
{
|
||||||
|
campo: 'Período início',
|
||||||
|
valor: relatorio.filtros.periodoInicio
|
||||||
|
? formatDateTime(relatorio.filtros.periodoInicio)
|
||||||
|
: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
campo: 'Período fim',
|
||||||
|
valor: relatorio.filtros.periodoFim ? formatDateTime(relatorio.filtros.periodoFim) : ''
|
||||||
|
},
|
||||||
|
{ campo: 'Número SEI (filtro)', valor: relatorio.filtros.numeroSei ?? '' },
|
||||||
|
{
|
||||||
|
campo: 'Status (filtro)',
|
||||||
|
valor: relatorio.filtros.statuses?.map(statusLabel).join(', ') ?? ''
|
||||||
|
},
|
||||||
|
{ campo: 'Total de pedidos', valor: relatorio.resumo.totalPedidos },
|
||||||
|
{ campo: 'Total de itens', valor: relatorio.resumo.totalItens },
|
||||||
|
{ campo: 'Total de documentos', valor: relatorio.resumo.totalDocumentos },
|
||||||
|
{ campo: 'Total valor estimado', valor: relatorio.resumo.totalValorEstimado },
|
||||||
|
{ campo: 'Total valor real', valor: relatorio.resumo.totalValorReal }
|
||||||
|
];
|
||||||
|
|
||||||
|
rows.forEach((r, idx) => {
|
||||||
|
const row = ws.addRow({ campo: r.campo, valor: r.valor });
|
||||||
|
applyZebraRowStyle(row, idx % 2 === 1);
|
||||||
|
row.getCell(2).alignment = {
|
||||||
|
vertical: 'middle',
|
||||||
|
horizontal: typeof r.valor === 'number' ? 'right' : 'left'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seção: Totais por status
|
||||||
|
ws.addRow({ campo: '', valor: '' });
|
||||||
|
const sectionRow = ws.addRow({ campo: 'Totais por status', valor: '' });
|
||||||
|
sectionRow.font = { bold: true, size: 11, color: argb('#2980B9') };
|
||||||
|
sectionRow.alignment = { vertical: 'middle', horizontal: 'left' };
|
||||||
|
|
||||||
|
relatorio.resumo.totalPorStatus.forEach((s, idx) => {
|
||||||
|
const row = ws.addRow({ campo: statusLabel(s.status), valor: s.count });
|
||||||
|
applyZebraRowStyle(row, idx % 2 === 1);
|
||||||
|
row.getCell(2).alignment = { vertical: 'middle', horizontal: 'right' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formatos numéricos
|
||||||
|
ws.getColumn(2).numFmt = '#,##0.00';
|
||||||
|
ws.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Aba: Pedidos =====
|
||||||
|
{
|
||||||
|
const ws = workbook.addWorksheet('Pedidos');
|
||||||
|
ws.columns = [
|
||||||
|
{ header: 'Nº SEI', key: 'numeroSei', width: 28 },
|
||||||
|
{ header: 'Status', key: 'status', width: 18 },
|
||||||
|
{ header: 'Criado por', key: 'criadoPor', width: 28 },
|
||||||
|
{ header: 'Aceito por', key: 'aceitoPor', width: 28 },
|
||||||
|
{ header: 'Criado em', key: 'criadoEm', width: 20 },
|
||||||
|
{ header: 'Concluído em', key: 'concluidoEm', width: 20 },
|
||||||
|
{ header: 'Itens', key: 'itens', width: 10 },
|
||||||
|
{ header: 'Docs', key: 'docs', width: 10 },
|
||||||
|
{ header: 'Estimado (R$)', key: 'estimado', width: 16 },
|
||||||
|
{ header: 'Real (R$)', key: 'real', width: 16 }
|
||||||
|
];
|
||||||
|
|
||||||
|
addTitleRow(ws, 'RELATÓRIO DE PEDIDOS — LISTA', ws.columns.length, workbook, logoBuffer);
|
||||||
|
|
||||||
|
const headerRow = ws.getRow(2);
|
||||||
|
headerRow.values = ws.columns.map((c) => c.header as string);
|
||||||
|
applyHeaderRowStyle(headerRow);
|
||||||
|
|
||||||
|
relatorio.pedidos.forEach((p, idx) => {
|
||||||
|
const row = ws.addRow({
|
||||||
|
numeroSei: p.numeroSei ?? '',
|
||||||
|
status: statusLabel(p.status),
|
||||||
|
criadoPor: p.criadoPorNome,
|
||||||
|
aceitoPor: p.aceitoPorNome ?? '',
|
||||||
|
criadoEm: p.criadoEm ? new Date(p.criadoEm) : null,
|
||||||
|
concluidoEm: p.concluidoEm ? new Date(p.concluidoEm) : null,
|
||||||
|
itens: p.itensCount,
|
||||||
|
docs: p.documentosCount,
|
||||||
|
estimado: p.valorEstimadoTotal,
|
||||||
|
real: p.valorRealTotal
|
||||||
|
});
|
||||||
|
|
||||||
|
applyZebraRowStyle(row, idx % 2 === 1);
|
||||||
|
|
||||||
|
// Alinhamentos
|
||||||
|
row.getCell(2).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
row.getCell(5).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
row.getCell(6).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
row.getCell(7).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
row.getCell(8).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
row.getCell(9).alignment = { vertical: 'middle', horizontal: 'right' };
|
||||||
|
row.getCell(10).alignment = { vertical: 'middle', horizontal: 'right' };
|
||||||
|
|
||||||
|
// Destaque por status
|
||||||
|
const statusCell = row.getCell(2);
|
||||||
|
if (p.status === 'concluido') {
|
||||||
|
statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#D4EDDA') };
|
||||||
|
statusCell.font = { size: 10, color: argb('#155724') };
|
||||||
|
} else if (p.status === 'cancelado') {
|
||||||
|
statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#F8D7DA') };
|
||||||
|
statusCell.font = { size: 10, color: argb('#721C24') };
|
||||||
|
} else if (p.status === 'aguardando_aceite') {
|
||||||
|
statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#FFF3CD') };
|
||||||
|
statusCell.font = { size: 10, color: argb('#856404') };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formatos
|
||||||
|
ws.getColumn(5).numFmt = 'dd/mm/yyyy hh:mm';
|
||||||
|
ws.getColumn(6).numFmt = 'dd/mm/yyyy hh:mm';
|
||||||
|
ws.getColumn(9).numFmt = '"R$" #,##0.00';
|
||||||
|
ws.getColumn(10).numFmt = '"R$" #,##0.00';
|
||||||
|
|
||||||
|
ws.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }];
|
||||||
|
ws.autoFilter = {
|
||||||
|
from: { row: 2, column: 1 },
|
||||||
|
to: { row: 2, column: ws.columns.length }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Aba: Itens =====
|
||||||
|
{
|
||||||
|
const ws = workbook.addWorksheet('Itens');
|
||||||
|
ws.columns = [
|
||||||
|
{ header: 'Pedido Nº SEI', key: 'pedidoNumeroSei', width: 28 },
|
||||||
|
{ header: 'Pedido Status', key: 'pedidoStatus', width: 18 },
|
||||||
|
{ header: 'Objeto', key: 'objeto', width: 45 },
|
||||||
|
{ header: 'Modalidade', key: 'modalidade', width: 16 },
|
||||||
|
{ header: 'Qtd', key: 'qtd', width: 10 },
|
||||||
|
{ header: 'Estimado (texto)', key: 'estimadoTxt', width: 18 },
|
||||||
|
{ header: 'Real (texto)', key: 'realTxt', width: 18 },
|
||||||
|
{ header: 'Adicionado por', key: 'adicionadoPor', width: 28 },
|
||||||
|
{ header: 'Ação', key: 'acao', width: 22 },
|
||||||
|
{ header: 'Ata', key: 'ata', width: 16 },
|
||||||
|
{ header: 'Criado em', key: 'criadoEm', width: 20 }
|
||||||
|
];
|
||||||
|
|
||||||
|
addTitleRow(ws, 'RELATÓRIO DE PEDIDOS — ITENS', ws.columns.length, workbook, logoBuffer);
|
||||||
|
|
||||||
|
const headerRow = ws.getRow(2);
|
||||||
|
headerRow.values = ws.columns.map((c) => c.header as string);
|
||||||
|
applyHeaderRowStyle(headerRow);
|
||||||
|
|
||||||
|
relatorio.itens.forEach((i, idx) => {
|
||||||
|
const row = ws.addRow({
|
||||||
|
pedidoNumeroSei: i.pedidoNumeroSei ?? '',
|
||||||
|
pedidoStatus: statusLabel(i.pedidoStatus),
|
||||||
|
objeto: i.objetoNome ?? String(i.objetoId),
|
||||||
|
modalidade: i.modalidade,
|
||||||
|
qtd: i.quantidade,
|
||||||
|
estimadoTxt: i.valorEstimado,
|
||||||
|
realTxt: i.valorReal ?? '',
|
||||||
|
adicionadoPor: i.adicionadoPorNome,
|
||||||
|
acao: i.acaoNome ?? '',
|
||||||
|
ata: i.ataNumero ?? '',
|
||||||
|
criadoEm: i.criadoEm ? new Date(i.criadoEm) : null
|
||||||
|
});
|
||||||
|
|
||||||
|
applyZebraRowStyle(row, idx % 2 === 1);
|
||||||
|
row.getCell(2).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
row.getCell(4).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
row.getCell(5).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
row.getCell(11).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.getColumn(11).numFmt = 'dd/mm/yyyy hh:mm';
|
||||||
|
ws.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }];
|
||||||
|
ws.autoFilter = {
|
||||||
|
from: { row: 2, column: 1 },
|
||||||
|
to: { row: 2, column: ws.columns.length }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nomeArquivo = `relatorio-pedidos-${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
downloadExcel(buffer, nomeArquivo);
|
||||||
|
}
|
||||||
211
apps/web/src/lib/utils/pedidos/relatorioPedidosPDF.ts
Normal file
211
apps/web/src/lib/utils/pedidos/relatorioPedidosPDF.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import type { FunctionReturnType } from 'convex/server';
|
||||||
|
import jsPDF from 'jspdf';
|
||||||
|
import autoTable from 'jspdf-autotable';
|
||||||
|
|
||||||
|
export type RelatorioPedidosData = FunctionReturnType<typeof api.pedidos.gerarRelatorio>;
|
||||||
|
|
||||||
|
function formatCurrencyBRL(n: number): string {
|
||||||
|
return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(ts: number | undefined): string {
|
||||||
|
if (!ts) return '-';
|
||||||
|
return new Date(ts).toLocaleString('pt-BR');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPeriodoLabel(filtros: RelatorioPedidosData['filtros']): string {
|
||||||
|
const inicio = filtros.periodoInicio ? formatDateTime(filtros.periodoInicio) : '—';
|
||||||
|
const fim = filtros.periodoFim ? formatDateTime(filtros.periodoFim) : '—';
|
||||||
|
return `${inicio} até ${fim}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gerarRelatorioPedidosPDF(relatorio: RelatorioPedidosData): void {
|
||||||
|
const doc = new jsPDF({ orientation: 'landscape' });
|
||||||
|
|
||||||
|
// Título
|
||||||
|
doc.setFontSize(18);
|
||||||
|
doc.setTextColor(102, 126, 234);
|
||||||
|
doc.text('Relatório de Pedidos', 14, 18);
|
||||||
|
|
||||||
|
// Subtítulo
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
doc.text(`Período: ${getPeriodoLabel(relatorio.filtros)}`, 14, 26);
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 14, 32);
|
||||||
|
|
||||||
|
let yPos = 40;
|
||||||
|
|
||||||
|
// Resumo
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setTextColor(102, 126, 234);
|
||||||
|
doc.text('Resumo', 14, yPos);
|
||||||
|
yPos += 6;
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: yPos,
|
||||||
|
head: [['Pedidos', 'Itens', 'Documentos', 'Total Estimado', 'Total Real']],
|
||||||
|
body: [
|
||||||
|
[
|
||||||
|
String(relatorio.resumo.totalPedidos),
|
||||||
|
String(relatorio.resumo.totalItens),
|
||||||
|
String(relatorio.resumo.totalDocumentos),
|
||||||
|
formatCurrencyBRL(relatorio.resumo.totalValorEstimado),
|
||||||
|
formatCurrencyBRL(relatorio.resumo.totalValorReal)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
theme: 'striped',
|
||||||
|
headStyles: { fillColor: [102, 126, 234] },
|
||||||
|
styles: { fontSize: 9 }
|
||||||
|
});
|
||||||
|
|
||||||
|
type JsPDFWithAutoTable = jsPDF & { lastAutoTable?: { finalY: number } };
|
||||||
|
yPos = ((doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPos) + 10;
|
||||||
|
|
||||||
|
// Totais por status
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setTextColor(102, 126, 234);
|
||||||
|
doc.text('Totais por status', 14, yPos);
|
||||||
|
yPos += 6;
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: yPos,
|
||||||
|
head: [['Status', 'Quantidade']],
|
||||||
|
body: relatorio.resumo.totalPorStatus.map((s) => [s.status, String(s.count)]),
|
||||||
|
theme: 'grid',
|
||||||
|
headStyles: { fillColor: [102, 126, 234] },
|
||||||
|
styles: { fontSize: 9 }
|
||||||
|
});
|
||||||
|
|
||||||
|
yPos = ((doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPos) + 12;
|
||||||
|
|
||||||
|
// Pedidos
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setTextColor(102, 126, 234);
|
||||||
|
doc.text('Pedidos', 14, yPos);
|
||||||
|
yPos += 6;
|
||||||
|
|
||||||
|
const pedidosBody = relatorio.pedidos.map((p) => [
|
||||||
|
p.numeroSei ?? '—',
|
||||||
|
p.status,
|
||||||
|
p.criadoPorNome,
|
||||||
|
p.aceitoPorNome ?? '—',
|
||||||
|
formatDateTime(p.criadoEm),
|
||||||
|
formatDateTime(p.concluidoEm),
|
||||||
|
String(p.itensCount),
|
||||||
|
String(p.documentosCount),
|
||||||
|
formatCurrencyBRL(p.valorEstimadoTotal),
|
||||||
|
formatCurrencyBRL(p.valorRealTotal)
|
||||||
|
]);
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: yPos,
|
||||||
|
head: [
|
||||||
|
[
|
||||||
|
'Nº SEI',
|
||||||
|
'Status',
|
||||||
|
'Criado por',
|
||||||
|
'Aceito por',
|
||||||
|
'Criado em',
|
||||||
|
'Concluído em',
|
||||||
|
'Itens',
|
||||||
|
'Docs',
|
||||||
|
'Estimado',
|
||||||
|
'Real'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
body: pedidosBody,
|
||||||
|
theme: 'grid',
|
||||||
|
headStyles: { fillColor: [102, 126, 234] },
|
||||||
|
styles: { fontSize: 7 },
|
||||||
|
columnStyles: {
|
||||||
|
0: { cellWidth: 36 },
|
||||||
|
1: { cellWidth: 26 },
|
||||||
|
2: { cellWidth: 32 },
|
||||||
|
3: { cellWidth: 32 },
|
||||||
|
4: { cellWidth: 30 },
|
||||||
|
5: { cellWidth: 30 },
|
||||||
|
6: { cellWidth: 12 },
|
||||||
|
7: { cellWidth: 12 },
|
||||||
|
8: { cellWidth: 22 },
|
||||||
|
9: { cellWidth: 22 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
yPos = ((doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPos) + 12;
|
||||||
|
|
||||||
|
// Itens (limitado para evitar PDF gigantesco)
|
||||||
|
const itensMax = 1000;
|
||||||
|
const itensToPrint = relatorio.itens.slice(0, itensMax);
|
||||||
|
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setTextColor(102, 126, 234);
|
||||||
|
doc.text(
|
||||||
|
`Itens (${itensToPrint.length}${relatorio.itens.length > itensMax ? ` de ${relatorio.itens.length}` : ''})`,
|
||||||
|
14,
|
||||||
|
yPos
|
||||||
|
);
|
||||||
|
yPos += 6;
|
||||||
|
|
||||||
|
const itensBody = itensToPrint.map((i) => [
|
||||||
|
i.pedidoNumeroSei ?? '—',
|
||||||
|
i.pedidoStatus,
|
||||||
|
i.objetoNome ?? String(i.objetoId),
|
||||||
|
i.modalidade,
|
||||||
|
String(i.quantidade),
|
||||||
|
i.valorEstimado,
|
||||||
|
i.valorReal ?? '—',
|
||||||
|
i.adicionadoPorNome,
|
||||||
|
formatDateTime(i.criadoEm)
|
||||||
|
]);
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: yPos,
|
||||||
|
head: [
|
||||||
|
[
|
||||||
|
'Nº SEI',
|
||||||
|
'Status',
|
||||||
|
'Objeto',
|
||||||
|
'Modalidade',
|
||||||
|
'Qtd',
|
||||||
|
'Estimado',
|
||||||
|
'Real',
|
||||||
|
'Adicionado por',
|
||||||
|
'Criado em'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
body: itensBody,
|
||||||
|
theme: 'grid',
|
||||||
|
headStyles: { fillColor: [102, 126, 234] },
|
||||||
|
styles: { fontSize: 7 },
|
||||||
|
columnStyles: {
|
||||||
|
0: { cellWidth: 34 },
|
||||||
|
1: { cellWidth: 22 },
|
||||||
|
2: { cellWidth: 55 },
|
||||||
|
3: { cellWidth: 24 },
|
||||||
|
4: { cellWidth: 10 },
|
||||||
|
5: { cellWidth: 22 },
|
||||||
|
6: { cellWidth: 22 },
|
||||||
|
7: { cellWidth: 32 },
|
||||||
|
8: { cellWidth: 28 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Footer com paginação
|
||||||
|
const pageCount = doc.getNumberOfPages();
|
||||||
|
for (let i = 1; i <= pageCount; i++) {
|
||||||
|
doc.setPage(i);
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(128, 128, 128);
|
||||||
|
doc.text(
|
||||||
|
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
||||||
|
doc.internal.pageSize.getWidth() / 2,
|
||||||
|
doc.internal.pageSize.getHeight() - 8,
|
||||||
|
{ align: 'center' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `relatorio-pedidos-${new Date().toISOString().slice(0, 10)}.pdf`;
|
||||||
|
doc.save(fileName);
|
||||||
|
}
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
import { createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit';
|
import { createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit';
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import { error, redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { FunctionReference } from 'convex/server';
|
|
||||||
|
|
||||||
export const load = async ({ locals, url }) => {
|
export const load = async ({ locals, url }) => {
|
||||||
if (!locals.token) {
|
if (!locals.token) {
|
||||||
throw redirect(302, '/login?redirect=' + url.pathname);
|
throw redirect(302, '/login?redirect=' + url.pathname);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const client = createConvexHttpClient({ token: locals.token });
|
const client = createConvexHttpClient({ token: locals.token });
|
||||||
const currentUser = await client.query(api.auth.getCurrentUser as FunctionReference<'query'>);
|
const currentUser = await client.query(api.auth.getCurrentUser);
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
throw redirect(302, '/login?redirect=' + url.pathname);
|
throw redirect(302, '/login?redirect=' + url.pathname);
|
||||||
}
|
}
|
||||||
return { currentUser };
|
return { currentUser };
|
||||||
} catch {
|
|
||||||
return error(500, 'Ops! Ocorreu um erro, tente novamente mais tarde.');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Building2, FileText, Package, ShoppingCart } from 'lucide-svelte';
|
import { Building2, FileText, Package, ShoppingCart } from 'lucide-svelte';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ProtectedRoute>
|
<main class="container mx-auto px-4 py-4">
|
||||||
<main class="container mx-auto px-4 py-4">
|
|
||||||
<div class="breadcrumbs mb-4 text-sm">
|
<div class="breadcrumbs mb-4 text-sm">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||||
@@ -94,5 +92,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</ProtectedRoute>
|
|
||||||
|
|||||||
@@ -2,12 +2,28 @@
|
|||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
import { Pencil, Plus, Trash2, X, Search, Check } from 'lucide-svelte';
|
import { Pencil, Plus, Trash2, X, Search, Check, FileText } from 'lucide-svelte';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { formatarDataBR } from '$lib/utils/datas';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
// Reactive queries
|
// Reactive queries
|
||||||
const atasQuery = useQuery(api.atas.list, {});
|
// Filtros (listagem)
|
||||||
|
let filtroPeriodoInicio = $state('');
|
||||||
|
let filtroPeriodoFim = $state('');
|
||||||
|
let filtroNumero = $state('');
|
||||||
|
let filtroNumeroSei = $state('');
|
||||||
|
|
||||||
|
const atasTotalQuery = useQuery(api.atas.list, {});
|
||||||
|
let atasTotal = $derived(atasTotalQuery.data || []);
|
||||||
|
|
||||||
|
const atasQuery = useQuery(api.atas.list, () => ({
|
||||||
|
periodoInicio: filtroPeriodoInicio || undefined,
|
||||||
|
periodoFim: filtroPeriodoFim || undefined,
|
||||||
|
numero: filtroNumero.trim() || undefined,
|
||||||
|
numeroSei: filtroNumeroSei.trim() || undefined
|
||||||
|
}));
|
||||||
let atas = $derived(atasQuery.data || []);
|
let atas = $derived(atasQuery.data || []);
|
||||||
let loadingAtas = $derived(atasQuery.isLoading);
|
let loadingAtas = $derived(atasQuery.isLoading);
|
||||||
let errorAtas = $derived(atasQuery.error?.message || null);
|
let errorAtas = $derived(atasQuery.error?.message || null);
|
||||||
@@ -26,9 +42,15 @@
|
|||||||
numeroSei: '',
|
numeroSei: '',
|
||||||
empresaId: '' as Id<'empresas'> | '',
|
empresaId: '' as Id<'empresas'> | '',
|
||||||
dataInicio: '',
|
dataInicio: '',
|
||||||
dataFim: ''
|
dataFim: '',
|
||||||
|
dataProrrogacao: ''
|
||||||
});
|
});
|
||||||
let selectedObjetos = $state<Id<'objetos'>[]>([]);
|
let selectedObjetos = $state<Id<'objetos'>[]>([]);
|
||||||
|
type ObjetoAtaConfig = {
|
||||||
|
quantidadeTotal: number | undefined;
|
||||||
|
limitePercentual: number | undefined;
|
||||||
|
};
|
||||||
|
let objetosConfig = $state<Record<string, ObjetoAtaConfig>>({});
|
||||||
let searchObjeto = $state('');
|
let searchObjeto = $state('');
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
@@ -52,12 +74,22 @@
|
|||||||
numeroSei: ata.numeroSei,
|
numeroSei: ata.numeroSei,
|
||||||
empresaId: ata.empresaId,
|
empresaId: ata.empresaId,
|
||||||
dataInicio: ata.dataInicio || '',
|
dataInicio: ata.dataInicio || '',
|
||||||
dataFim: ata.dataFim || ''
|
dataFim: ata.dataFim || '',
|
||||||
|
dataProrrogacao: ata.dataProrrogacao || ''
|
||||||
};
|
};
|
||||||
// Fetch linked objects
|
// Fetch linked objects
|
||||||
const linkedObjetos = await client.query(api.atas.getObjetos, { id: ata._id });
|
const linkedObjetos = await client.query(api.atas.getObjetos, { id: ata._id });
|
||||||
selectedObjetos = linkedObjetos.map((o) => o._id);
|
selectedObjetos = linkedObjetos.map((o) => o._id);
|
||||||
|
|
||||||
|
const linkedConfigs = await client.query(api.atas.getObjetosConfig, { id: ata._id });
|
||||||
|
objetosConfig = {};
|
||||||
|
for (const cfg of linkedConfigs) {
|
||||||
|
objetosConfig[String(cfg.objetoId)] = {
|
||||||
|
quantidadeTotal: cfg.quantidadeTotal ?? undefined,
|
||||||
|
limitePercentual: cfg.limitePercentual ?? 50
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch attachments
|
// Fetch attachments
|
||||||
attachments = await client.query(api.atas.getDocumentos, { ataId: ata._id });
|
attachments = await client.query(api.atas.getDocumentos, { ataId: ata._id });
|
||||||
} else {
|
} else {
|
||||||
@@ -67,9 +99,11 @@
|
|||||||
numeroSei: '',
|
numeroSei: '',
|
||||||
empresaId: '',
|
empresaId: '',
|
||||||
dataInicio: '',
|
dataInicio: '',
|
||||||
dataFim: ''
|
dataFim: '',
|
||||||
|
dataProrrogacao: ''
|
||||||
};
|
};
|
||||||
selectedObjetos = [];
|
selectedObjetos = [];
|
||||||
|
objetosConfig = {};
|
||||||
attachments = [];
|
attachments = [];
|
||||||
}
|
}
|
||||||
attachmentFiles = [];
|
attachmentFiles = [];
|
||||||
@@ -82,11 +116,22 @@
|
|||||||
editingId = null;
|
editingId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getObjetoConfig(id: Id<'objetos'>): ObjetoAtaConfig {
|
||||||
|
const key = String(id);
|
||||||
|
if (!objetosConfig[key]) {
|
||||||
|
objetosConfig[key] = { quantidadeTotal: 1, limitePercentual: 50 };
|
||||||
|
}
|
||||||
|
return objetosConfig[key];
|
||||||
|
}
|
||||||
|
|
||||||
function toggleObjeto(id: Id<'objetos'>) {
|
function toggleObjeto(id: Id<'objetos'>) {
|
||||||
|
const key = String(id);
|
||||||
if (selectedObjetos.includes(id)) {
|
if (selectedObjetos.includes(id)) {
|
||||||
selectedObjetos = selectedObjetos.filter((oid) => oid !== id);
|
selectedObjetos = selectedObjetos.filter((oid) => oid !== id);
|
||||||
|
delete objetosConfig[key];
|
||||||
} else {
|
} else {
|
||||||
selectedObjetos = [...selectedObjetos, id];
|
selectedObjetos = [...selectedObjetos, id];
|
||||||
|
objetosConfig[key] = { quantidadeTotal: 1, limitePercentual: 50 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,13 +154,38 @@
|
|||||||
}
|
}
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
|
const objetos = selectedObjetos.map((objetoId) => {
|
||||||
|
const cfg = objetosConfig[String(objetoId)];
|
||||||
|
if (
|
||||||
|
!cfg ||
|
||||||
|
cfg.quantidadeTotal === undefined ||
|
||||||
|
!Number.isFinite(cfg.quantidadeTotal) ||
|
||||||
|
cfg.quantidadeTotal <= 0
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Informe a quantidade (maior que zero) para todos os objetos vinculados.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const limitePercentual =
|
||||||
|
cfg.limitePercentual === undefined || !Number.isFinite(cfg.limitePercentual)
|
||||||
|
? 50
|
||||||
|
: cfg.limitePercentual;
|
||||||
|
|
||||||
|
return {
|
||||||
|
objetoId,
|
||||||
|
quantidadeTotal: cfg.quantidadeTotal,
|
||||||
|
limitePercentual
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
numero: formData.numero,
|
numero: formData.numero,
|
||||||
numeroSei: formData.numeroSei,
|
numeroSei: formData.numeroSei,
|
||||||
empresaId: formData.empresaId as Id<'empresas'>,
|
empresaId: formData.empresaId as Id<'empresas'>,
|
||||||
dataInicio: formData.dataInicio || undefined,
|
dataInicio: formData.dataInicio || undefined,
|
||||||
dataFim: formData.dataFim || undefined,
|
dataFim: formData.dataFim || undefined,
|
||||||
objetosIds: selectedObjetos
|
dataProrrogacao: formData.dataProrrogacao || undefined,
|
||||||
|
objetos
|
||||||
};
|
};
|
||||||
|
|
||||||
let ataId: Id<'atas'>;
|
let ataId: Id<'atas'>;
|
||||||
@@ -187,142 +257,258 @@
|
|||||||
attachmentFiles = Array.from(input.files);
|
attachmentFiles = Array.from(input.files);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function limparFiltros() {
|
||||||
|
filtroPeriodoInicio = '';
|
||||||
|
filtroPeriodoFim = '';
|
||||||
|
filtroNumero = '';
|
||||||
|
filtroNumeroSei = '';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container mx-auto p-6">
|
<main class="container mx-auto flex max-w-7xl flex-col px-4 py-4">
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="breadcrumbs mb-4 text-sm">
|
||||||
<h1 class="text-2xl font-bold">Atas de Registro de Preços</h1>
|
<ul>
|
||||||
|
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||||
|
<li><a href={resolve('/compras')} class="text-primary hover:underline">Compras</a></li>
|
||||||
|
<li>Atas</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="bg-accent/10 rounded-xl p-3">
|
||||||
|
<FileText class="text-accent h-8 w-8" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-primary text-3xl font-bold">Atas de Registro de Preços</h1>
|
||||||
|
<p class="text-base-content/70">Gerencie atas, vigência, empresa e anexos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
|
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
|
||||||
onclick={() => openModal()}
|
onclick={() => openModal()}
|
||||||
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
|
||||||
>
|
>
|
||||||
<Plus size={20} />
|
<Plus class="h-5 w-5" strokeWidth={2} />
|
||||||
Nova Ata
|
Nova Ata
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100/90 border-base-300 mb-6 border shadow-xl backdrop-blur-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_numero">
|
||||||
|
<span class="label-text font-semibold">Número</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro_numero"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex.: 12/2025"
|
||||||
|
bind:value={filtroNumero}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_numeroSei">
|
||||||
|
<span class="label-text font-semibold">Número SEI</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro_numeroSei"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex.: 12345.000000/2025-00"
|
||||||
|
bind:value={filtroNumeroSei}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_inicio">
|
||||||
|
<span class="label-text font-semibold">Período (início)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro_inicio"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
type="date"
|
||||||
|
bind:value={filtroPeriodoInicio}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_fim">
|
||||||
|
<span class="label-text font-semibold">Período (fim)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro_fim"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
type="date"
|
||||||
|
bind:value={filtroPeriodoFim}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-3 sm:min-w-max sm:justify-end">
|
||||||
|
<div class="text-base-content/70 text-sm">
|
||||||
|
{atas.length} de {atasTotal.length}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={limparFiltros}>Limpar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if loadingAtas}
|
{#if loadingAtas}
|
||||||
<p>Carregando...</p>
|
<div class="flex items-center justify-center py-10">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
{:else if errorAtas}
|
{:else if errorAtas}
|
||||||
<p class="text-red-600">{errorAtas}</p>
|
<div class="alert alert-error">
|
||||||
|
<span>{errorAtas}</span>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-hidden rounded-lg bg-white shadow-md">
|
<div class="card bg-base-100/90 border-base-300 border shadow-xl backdrop-blur-sm">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<div class="card-body p-0">
|
||||||
<thead class="bg-gray-50">
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-zebra table w-full">
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Número</th
|
>Número</th
|
||||||
>
|
>
|
||||||
<th
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>SEI</th
|
>SEI</th
|
||||||
>
|
>
|
||||||
<th
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Empresa</th
|
>Empresa</th
|
||||||
>
|
>
|
||||||
<th
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Vigência</th
|
>Vigência</th
|
||||||
>
|
>
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
|
||||||
>Ações</th
|
>Ações</th
|
||||||
>
|
>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
<tbody>
|
||||||
{#each atas as ata (ata._id)}
|
{#if atas.length === 0}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-4 font-medium whitespace-nowrap">{ata.numero}</td>
|
<td colspan="5" class="py-12 text-center">
|
||||||
<td class="px-6 py-4 whitespace-nowrap">{ata.numeroSei}</td>
|
<div class="text-base-content/60 flex flex-col items-center gap-2">
|
||||||
|
{#if atasTotal.length === 0}
|
||||||
|
<p class="text-lg font-semibold">Nenhuma ata cadastrada</p>
|
||||||
|
<p class="text-sm">Clique em “Nova Ata” para cadastrar.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-lg font-semibold">Nenhum resultado encontrado</p>
|
||||||
|
<p class="text-sm">Ajuste ou limpe os filtros para ver resultados.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
{#each atas as ata (ata._id)}
|
||||||
|
<tr class="hover:bg-base-200/50 transition-colors">
|
||||||
|
<td class="font-medium whitespace-nowrap">{ata.numero}</td>
|
||||||
|
<td class="whitespace-nowrap">{ata.numeroSei}</td>
|
||||||
<td
|
<td
|
||||||
class="max-w-md truncate px-6 py-4 whitespace-nowrap"
|
class="max-w-md truncate whitespace-nowrap"
|
||||||
title={getEmpresaNome(ata.empresaId)}
|
title={getEmpresaNome(ata.empresaId)}
|
||||||
>
|
>
|
||||||
{getEmpresaNome(ata.empresaId)}
|
{getEmpresaNome(ata.empresaId)}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
<td class="text-base-content/70 whitespace-nowrap">
|
||||||
{ata.dataInicio || '-'} a {ata.dataFim || '-'}
|
{ata.dataInicio ? formatarDataBR(ata.dataInicio) : '-'} a
|
||||||
|
{ata.dataFim ? formatarDataBR(ata.dataFim) : '-'}
|
||||||
|
{#if ata.dataProrrogacao}
|
||||||
|
<span class="text-base-content/50">
|
||||||
|
(prorrogação: {formatarDataBR(ata.dataProrrogacao)})</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
<td class="text-right whitespace-nowrap">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
aria-label="Editar ata"
|
||||||
onclick={() => openModal(ata)}
|
onclick={() => openModal(ata)}
|
||||||
class="mr-4 text-indigo-600 hover:text-indigo-900"
|
|
||||||
>
|
>
|
||||||
<Pencil size={18} />
|
<Pencil size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm text-error"
|
||||||
|
aria-label="Excluir ata"
|
||||||
onclick={() => handleDelete(ata._id)}
|
onclick={() => handleDelete(ata._id)}
|
||||||
class="text-red-600 hover:text-red-900"
|
|
||||||
>
|
>
|
||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{#if atas.length === 0}
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="px-6 py-4 text-center text-gray-500"
|
|
||||||
>Nenhuma ata cadastrada.</td
|
|
||||||
>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showModal}
|
{#if showModal}
|
||||||
<div
|
<div class="modal modal-open">
|
||||||
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40"
|
<div class="modal-box max-w-4xl">
|
||||||
>
|
|
||||||
<div class="relative my-8 w-full max-w-2xl rounded-lg bg-white p-8 shadow-xl">
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-circle absolute top-2 right-2"
|
||||||
onclick={closeModal}
|
onclick={closeModal}
|
||||||
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
|
aria-label="Fechar modal"
|
||||||
>
|
>
|
||||||
<X size={24} />
|
<X class="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<h2 class="mb-6 text-xl font-bold">{editingId ? 'Editar' : 'Nova'} Ata</h2>
|
|
||||||
|
|
||||||
<form onsubmit={handleSubmit}>
|
<h3 class="text-lg font-bold">{editingId ? 'Editar' : 'Nova'} Ata</h3>
|
||||||
|
|
||||||
|
<form class="mt-6 space-y-6" onsubmit={handleSubmit}>
|
||||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
<div>
|
<div class="space-y-4">
|
||||||
<div class="mb-4">
|
<div class="form-control w-full">
|
||||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="numero">
|
<label class="label" for="numero">
|
||||||
Número da Ata
|
<span class="label-text font-semibold">Número da Ata</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
|
||||||
id="numero"
|
id="numero"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={formData.numero}
|
bind:value={formData.numero}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="form-control w-full">
|
||||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="numeroSei">
|
<label class="label" for="numeroSei">
|
||||||
Número SEI
|
<span class="label-text font-semibold">Número SEI</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
|
||||||
id="numeroSei"
|
id="numeroSei"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={formData.numeroSei}
|
bind:value={formData.numeroSei}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="form-control w-full">
|
||||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="empresa">
|
<label class="label" for="empresa">
|
||||||
Empresa
|
<span class="label-text font-semibold">Empresa</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
class="focus:shadow-outline w-full rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
|
||||||
id="empresa"
|
id="empresa"
|
||||||
|
class="select select-bordered focus:select-primary w-full"
|
||||||
bind:value={formData.empresaId}
|
bind:value={formData.empresaId}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@@ -333,107 +519,174 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<div>
|
<div class="form-control w-full">
|
||||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="dataInicio">
|
<label class="label" for="dataInicio">
|
||||||
Data Início
|
<span class="label-text font-semibold">Data Início</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
|
||||||
id="dataInicio"
|
id="dataInicio"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
type="date"
|
type="date"
|
||||||
bind:value={formData.dataInicio}
|
bind:value={formData.dataInicio}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="form-control w-full">
|
||||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="dataFim">
|
<label class="label" for="dataFim">
|
||||||
Data Fim
|
<span class="label-text font-semibold">Data Fim</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
|
||||||
id="dataFim"
|
id="dataFim"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
type="date"
|
type="date"
|
||||||
bind:value={formData.dataFim}
|
bind:value={formData.dataFim}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="form-control w-full">
|
||||||
</div>
|
<label class="label" for="dataProrrogacao">
|
||||||
|
<span class="label-text font-semibold">Data Prorrogação</span>
|
||||||
<div class="flex flex-col">
|
|
||||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="objetos">
|
|
||||||
Objetos Vinculados ({selectedObjetos.length})
|
|
||||||
</label>
|
</label>
|
||||||
|
<input
|
||||||
|
id="dataProrrogacao"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
type="date"
|
||||||
|
bind:value={formData.dataProrrogacao}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="relative mb-2">
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="font-semibold">Objetos Vinculados</div>
|
||||||
|
<span class="badge badge-outline">{selectedObjetos.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="buscar_objeto">
|
||||||
|
<span class="label-text font-semibold">Buscar objetos</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<Search size={16} class="text-gray-400" />
|
<Search size={16} class="text-base-content/40" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
id="buscar_objeto"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar objetos..."
|
placeholder="Digite para filtrar..."
|
||||||
class="focus:shadow-outline w-full rounded border py-2 pr-3 pl-10 text-sm leading-tight text-gray-700 shadow focus:outline-none"
|
class="input input-bordered focus:input-primary w-full pl-10"
|
||||||
bind:value={searchObjeto}
|
bind:value={searchObjeto}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="border-base-300 max-h-52 overflow-y-auto rounded-lg border p-2">
|
||||||
class="mb-4 flex-1 overflow-y-auto rounded border bg-gray-50 p-2"
|
|
||||||
style="max-height: 200px;"
|
|
||||||
>
|
|
||||||
{#if filteredObjetos.length === 0}
|
{#if filteredObjetos.length === 0}
|
||||||
<p class="py-4 text-center text-sm text-gray-500">Nenhum objeto encontrado.</p>
|
<p class="text-base-content/60 px-2 py-3 text-center text-sm">
|
||||||
|
Nenhum objeto encontrado.
|
||||||
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-1">
|
|
||||||
{#each filteredObjetos as objeto (objeto._id)}
|
{#each filteredObjetos as objeto (objeto._id)}
|
||||||
<button
|
{@const isSelected = selectedObjetos.includes(objeto._id)}
|
||||||
type="button"
|
<label
|
||||||
class="flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm hover:bg-gray-200 {selectedObjetos.includes(
|
class="hover:bg-base-200/50 flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 {isSelected
|
||||||
objeto._id
|
? 'bg-primary/5'
|
||||||
)
|
|
||||||
? 'bg-blue-50 text-blue-700'
|
|
||||||
: ''}"
|
: ''}"
|
||||||
onclick={() => toggleObjeto(objeto._id)}
|
|
||||||
>
|
>
|
||||||
<span class="truncate">{objeto.nome}</span>
|
<input
|
||||||
{#if selectedObjetos.includes(objeto._id)}
|
type="checkbox"
|
||||||
<Check size={16} class="text-blue-600" />
|
class="checkbox checkbox-primary checkbox-sm"
|
||||||
|
checked={isSelected}
|
||||||
|
onchange={() => toggleObjeto(objeto._id)}
|
||||||
|
aria-label="Vincular objeto {objeto.nome}"
|
||||||
|
/>
|
||||||
|
<span class="flex-1 truncate text-sm">{objeto.nome}</span>
|
||||||
|
{#if isSelected}
|
||||||
|
<Check size={16} class="text-primary" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t pt-4">
|
{#if selectedObjetos.length > 0}
|
||||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="anexos">
|
<div class="border-base-300 border-t pt-4">
|
||||||
Anexos
|
<div class="font-semibold">Configuração por objeto</div>
|
||||||
|
<p class="text-base-content/60 mt-1 text-xs">
|
||||||
|
Defina a quantidade total do objeto na ata e o limite de uso em % (padrão 50%).
|
||||||
|
</p>
|
||||||
|
<div class="mt-3 max-h-52 space-y-3 overflow-y-auto">
|
||||||
|
{#each selectedObjetos as objetoId (objetoId)}
|
||||||
|
{@const obj = objetos.find((o) => o._id === objetoId)}
|
||||||
|
{@const cfg = getObjetoConfig(objetoId)}
|
||||||
|
<div class="border-base-300 rounded-lg border p-3">
|
||||||
|
<div class="text-sm font-semibold">{obj?.nome || 'Objeto'}</div>
|
||||||
|
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for={`qtd_${objetoId}`}>
|
||||||
|
<span class="label-text text-xs font-semibold">Quantidade na ata</span
|
||||||
|
>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
class="focus:shadow-outline mb-2 w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
id={`qtd_${objetoId}`}
|
||||||
|
class="input input-bordered input-sm focus:input-primary w-full"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
bind:value={cfg.quantidadeTotal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for={`limite_${objetoId}`}>
|
||||||
|
<span class="label-text text-xs font-semibold">Limite (%)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`limite_${objetoId}`}
|
||||||
|
class="input input-bordered input-sm focus:input-primary w-full"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
placeholder="50"
|
||||||
|
bind:value={cfg.limitePercentual}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="border-base-300 border-t pt-4">
|
||||||
|
<div class="font-semibold">Anexos</div>
|
||||||
|
<div class="mt-2 space-y-2">
|
||||||
|
<input
|
||||||
id="anexos"
|
id="anexos"
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
|
class="file-input file-input-bordered w-full"
|
||||||
onchange={handleAttachmentsSelect}
|
onchange={handleAttachmentsSelect}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if attachments.length > 0}
|
{#if attachments.length > 0}
|
||||||
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto">
|
|
||||||
{#each attachments as doc (doc._id)}
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between rounded bg-gray-100 p-2 text-sm"
|
class="border-base-300 max-h-40 space-y-2 overflow-y-auto rounded-lg border p-2"
|
||||||
>
|
>
|
||||||
|
{#each attachments as doc (doc._id)}
|
||||||
|
<div class="flex items-center justify-between gap-2 text-sm">
|
||||||
<a
|
<a
|
||||||
href={doc.url}
|
href={doc.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="max-w-[150px] truncate text-blue-600 hover:underline"
|
class="link link-primary max-w-[260px] truncate"
|
||||||
>
|
>
|
||||||
{doc.nome}
|
{doc.nome}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
onclick={() => handleDeleteAttachment(doc._id)}
|
onclick={() => handleDeleteAttachment(doc._id)}
|
||||||
class="text-red-500 hover:text-red-700"
|
aria-label="Excluir anexo"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -444,25 +697,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex items-center justify-end border-t pt-4">
|
<div class="modal-action">
|
||||||
<button
|
<button type="button" class="btn" onclick={closeModal} disabled={saving || uploading}>
|
||||||
type="button"
|
|
||||||
onclick={closeModal}
|
|
||||||
class="mr-2 rounded bg-gray-300 px-4 py-2 font-bold text-gray-800 hover:bg-gray-400"
|
|
||||||
>
|
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="submit" class="btn btn-primary" disabled={saving || uploading}>
|
||||||
type="submit"
|
{#if saving || uploading}
|
||||||
disabled={saving || uploading}
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
class="focus:shadow-outline rounded bg-blue-600 px-4 py-2 font-bold text-white hover:bg-blue-700 focus:outline-none disabled:opacity-50"
|
{/if}
|
||||||
>
|
|
||||||
{saving || uploading ? 'Salvando...' : 'Salvar'}
|
{saving || uploading ? 'Salvando...' : 'Salvar'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Fechar modal"
|
||||||
|
></button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</main>
|
||||||
|
|||||||
@@ -2,13 +2,26 @@
|
|||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
import { Pencil, Plus, Trash2, X } from 'lucide-svelte';
|
import { Pencil, Plus, Trash2, X, Package } from 'lucide-svelte';
|
||||||
import { maskCurrencyBRL } from '$lib/utils/masks';
|
import { maskCurrencyBRL } from '$lib/utils/masks';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
// Reactive queries
|
// Reactive queries
|
||||||
const objetosQuery = useQuery(api.objetos.list, {});
|
// Filtros (listagem)
|
||||||
|
let filtroNome = $state('');
|
||||||
|
let filtroTipo = $state<'todos' | 'material' | 'servico'>('todos');
|
||||||
|
let filtroCodigos = $state('');
|
||||||
|
|
||||||
|
const objetosTotalQuery = useQuery(api.objetos.list, {});
|
||||||
|
let objetosTotal = $derived(objetosTotalQuery.data || []);
|
||||||
|
|
||||||
|
const objetosQuery = useQuery(api.objetos.list, () => ({
|
||||||
|
nome: filtroNome.trim() || undefined,
|
||||||
|
tipo: filtroTipo === 'todos' ? undefined : filtroTipo,
|
||||||
|
codigos: filtroCodigos.trim() || undefined
|
||||||
|
}));
|
||||||
let objetos = $derived(objetosQuery.data || []);
|
let objetos = $derived(objetosQuery.data || []);
|
||||||
let loading = $derived(objetosQuery.isLoading);
|
let loading = $derived(objetosQuery.isLoading);
|
||||||
let error = $derived(objetosQuery.error?.message || null);
|
let error = $derived(objetosQuery.error?.message || null);
|
||||||
@@ -114,58 +127,155 @@
|
|||||||
formData.atas = [...formData.atas, ataId];
|
formData.atas = [...formData.atas, ataId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function limparFiltros() {
|
||||||
|
filtroNome = '';
|
||||||
|
filtroTipo = 'todos';
|
||||||
|
filtroCodigos = '';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container mx-auto p-6">
|
<main class="container mx-auto flex max-w-7xl flex-col px-4 py-4">
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="breadcrumbs mb-4 text-sm">
|
||||||
<h1 class="text-2xl font-bold">Objetos</h1>
|
<ul>
|
||||||
|
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||||
|
<li><a href={resolve('/compras')} class="text-primary hover:underline">Compras</a></li>
|
||||||
|
<li>Objetos</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="bg-primary/10 rounded-xl p-3">
|
||||||
|
<Package class="text-primary h-8 w-8" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-primary text-3xl font-bold">Objetos</h1>
|
||||||
|
<p class="text-base-content/70">Cadastro e gestão de objetos e serviços</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
|
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
|
||||||
onclick={() => openModal()}
|
onclick={() => openModal()}
|
||||||
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
|
||||||
>
|
>
|
||||||
<Plus size={20} />
|
<Plus class="h-5 w-5" strokeWidth={2} />
|
||||||
Novo Objeto
|
Novo Objeto
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100/90 border-base-300 mb-6 border shadow-xl backdrop-blur-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_nome">
|
||||||
|
<span class="label-text font-semibold">Nome</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro_nome"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Digite para filtrar..."
|
||||||
|
bind:value={filtroNome}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_tipo">
|
||||||
|
<span class="label-text font-semibold">Tipo</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="filtro_tipo"
|
||||||
|
class="select select-bordered focus:select-primary w-full"
|
||||||
|
bind:value={filtroTipo}
|
||||||
|
>
|
||||||
|
<option value="todos">Todos</option>
|
||||||
|
<option value="material">Material</option>
|
||||||
|
<option value="servico">Serviço</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_codigos">
|
||||||
|
<span class="label-text font-semibold">Códigos</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro_codigos"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Efisco / Catmat / Catserv"
|
||||||
|
bind:value={filtroCodigos}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-3 sm:min-w-max sm:justify-end">
|
||||||
|
<div class="text-base-content/70 text-sm">
|
||||||
|
{objetos.length} de {objetosTotal.length}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={limparFiltros}>Limpar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p>Carregando...</p>
|
<div class="flex items-center justify-center py-10">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<p class="text-red-600">{error}</p>
|
<div class="alert alert-error">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-hidden rounded-lg bg-white shadow-md">
|
<div class="card bg-base-100/90 border-base-300 border shadow-xl backdrop-blur-sm">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<div class="card-body p-0">
|
||||||
<thead class="bg-gray-50">
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-zebra table w-full">
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Nome</th
|
>Nome</th
|
||||||
>
|
>
|
||||||
<th
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Tipo</th
|
>Tipo</th
|
||||||
>
|
>
|
||||||
<th
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Unidade</th
|
>Unidade</th
|
||||||
>
|
>
|
||||||
<th
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Valor Estimado</th
|
>Valor Estimado</th
|
||||||
>
|
>
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
|
||||||
>Ações</th
|
>Ações</th
|
||||||
>
|
>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
<tbody>
|
||||||
{#each objetos as objeto (objeto._id)}
|
{#if objetos.length === 0}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td colspan="5" class="py-12 text-center">
|
||||||
|
<div class="text-base-content/60 flex flex-col items-center gap-2">
|
||||||
|
{#if objetosTotal.length === 0}
|
||||||
|
<p class="text-lg font-semibold">Nenhum objeto cadastrado</p>
|
||||||
|
<p class="text-sm">Clique em “Novo Objeto” para cadastrar.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-lg font-semibold">Nenhum resultado encontrado</p>
|
||||||
|
<p class="text-sm">Ajuste ou limpe os filtros para ver resultados.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
{#each objetos as objeto (objeto._id)}
|
||||||
|
<tr class="hover:bg-base-200/50 transition-colors">
|
||||||
|
<td>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="font-medium">{objeto.nome}</span>
|
<span class="font-medium">{objeto.nome}</span>
|
||||||
<span class="text-xs text-gray-500">
|
<span class="text-base-content/60 text-xs">
|
||||||
Efisco: {objeto.codigoEfisco}
|
Efisco: {objeto.codigoEfisco}
|
||||||
{#if objeto.codigoCatmat}
|
{#if objeto.codigoCatmat}
|
||||||
| Catmat: {objeto.codigoCatmat}{/if}
|
| Catmat: {objeto.codigoCatmat}{/if}
|
||||||
@@ -174,92 +284,99 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="whitespace-nowrap">
|
||||||
<span
|
<span
|
||||||
class="inline-flex rounded-full px-2 text-xs leading-5 font-semibold
|
class="badge badge-sm {objeto.tipo === 'servico'
|
||||||
{objeto.tipo === 'servico'
|
? 'badge-success'
|
||||||
? 'bg-green-100 text-green-800'
|
: 'badge-info'}"
|
||||||
: 'bg-blue-100 text-blue-800'}"
|
|
||||||
>
|
>
|
||||||
{objeto.tipo === 'material' ? 'Material' : 'Serviço'}
|
{objeto.tipo === 'material' ? 'Material' : 'Serviço'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">{objeto.unidade}</td>
|
<td class="whitespace-nowrap">{objeto.unidade}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="whitespace-nowrap">
|
||||||
{maskCurrencyBRL(objeto.valorEstimado) || 'R$ 0,00'}
|
{maskCurrencyBRL(objeto.valorEstimado) || 'R$ 0,00'}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
<td class="text-right whitespace-nowrap">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
aria-label="Editar objeto"
|
||||||
onclick={() => openModal(objeto)}
|
onclick={() => openModal(objeto)}
|
||||||
class="mr-4 text-indigo-600 hover:text-indigo-900"
|
|
||||||
>
|
>
|
||||||
<Pencil size={18} />
|
<Pencil size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm text-error"
|
||||||
|
aria-label="Excluir objeto"
|
||||||
onclick={() => handleDelete(objeto._id)}
|
onclick={() => handleDelete(objeto._id)}
|
||||||
class="text-red-600 hover:text-red-900"
|
|
||||||
>
|
>
|
||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{#if objetos.length === 0}
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="px-6 py-4 text-center text-gray-500"
|
|
||||||
>Nenhum objeto cadastrado.</td
|
|
||||||
>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showModal}
|
{#if showModal}
|
||||||
<div
|
<div class="modal modal-open">
|
||||||
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40"
|
<div class="modal-box max-w-2xl">
|
||||||
>
|
|
||||||
<div class="relative w-full max-w-md rounded-lg bg-white p-8 shadow-xl">
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-circle absolute top-2 right-2"
|
||||||
onclick={closeModal}
|
onclick={closeModal}
|
||||||
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
|
aria-label="Fechar modal"
|
||||||
>
|
>
|
||||||
<X size={24} />
|
<X class="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<h2 class="mb-6 text-xl font-bold">{editingId ? 'Editar' : 'Novo'} Objeto</h2>
|
|
||||||
|
|
||||||
<form onsubmit={handleSubmit}>
|
<h3 class="text-lg font-bold">{editingId ? 'Editar' : 'Novo'} Objeto</h3>
|
||||||
<div class="mb-4">
|
|
||||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="nome"> Nome </label>
|
<form class="mt-6 space-y-4" onsubmit={handleSubmit}>
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="nome">
|
||||||
|
<span class="label-text font-semibold">Nome</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
|
||||||
id="nome"
|
id="nome"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={formData.nome}
|
bind:value={formData.nome}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div class="form-control w-full">
|
||||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="tipo"> Tipo </label>
|
<label class="label" for="tipo">
|
||||||
|
<span class="label-text font-semibold">Tipo</span>
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
class="focus:shadow-outline w-full rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
|
||||||
id="tipo"
|
id="tipo"
|
||||||
|
class="select select-bordered focus:select-primary w-full"
|
||||||
bind:value={formData.tipo}
|
bind:value={formData.tipo}
|
||||||
>
|
>
|
||||||
<option value="material">Material</option>
|
<option value="material">Material</option>
|
||||||
<option value="servico">Serviço</option>
|
<option value="servico">Serviço</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="unidade">
|
<div class="form-control w-full">
|
||||||
Unidade
|
<label class="label" for="unidade">
|
||||||
|
<span class="label-text font-semibold">Unidade</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
|
||||||
id="unidade"
|
id="unidade"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={formData.unidade}
|
bind:value={formData.unidade}
|
||||||
required
|
required
|
||||||
@@ -267,13 +384,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="form-control w-full">
|
||||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="codigoEfisco">
|
<label class="label" for="codigoEfisco">
|
||||||
Código Efisco
|
<span class="label-text font-semibold">Código Efisco</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
|
||||||
id="codigoEfisco"
|
id="codigoEfisco"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={formData.codigoEfisco}
|
bind:value={formData.codigoEfisco}
|
||||||
required
|
required
|
||||||
@@ -281,88 +398,86 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if formData.tipo === 'material'}
|
{#if formData.tipo === 'material'}
|
||||||
<div class="mb-4">
|
<div class="form-control w-full">
|
||||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="codigoCatmat">
|
<label class="label" for="codigoCatmat">
|
||||||
Código Catmat
|
<span class="label-text font-semibold">Código Catmat</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
|
||||||
id="codigoCatmat"
|
id="codigoCatmat"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={formData.codigoCatmat}
|
bind:value={formData.codigoCatmat}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mb-4">
|
<div class="form-control w-full">
|
||||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="codigoCatserv">
|
<label class="label" for="codigoCatserv">
|
||||||
Código Catserv
|
<span class="label-text font-semibold">Código Catserv</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
|
||||||
id="codigoCatserv"
|
id="codigoCatserv"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={formData.codigoCatserv}
|
bind:value={formData.codigoCatserv}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="mb-6">
|
<div class="form-control w-full">
|
||||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="valor">
|
<label class="label" for="valor">
|
||||||
Valor Estimado
|
<span class="label-text font-semibold">Valor Estimado</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
|
||||||
id="valor"
|
id="valor"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
type="text"
|
type="text"
|
||||||
|
placeholder="R$ 0,00"
|
||||||
bind:value={formData.valorEstimado}
|
bind:value={formData.valorEstimado}
|
||||||
oninput={(e) => (formData.valorEstimado = maskCurrencyBRL(e.currentTarget.value))}
|
oninput={(e) => (formData.valorEstimado = maskCurrencyBRL(e.currentTarget.value))}
|
||||||
placeholder="R$ 0,00"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-6">
|
<div class="form-control w-full">
|
||||||
<label class="mb-2 block text-sm font-bold text-gray-700" for="atas">
|
<label class="label" for="atas">
|
||||||
Vincular Atas
|
<span class="label-text font-semibold">Vincular Atas</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="max-h-40 overflow-y-auto rounded border p-2">
|
<div class="border-base-300 max-h-48 overflow-y-auto rounded-lg border p-2">
|
||||||
|
{#if atas.length === 0}
|
||||||
|
<p class="text-base-content/60 px-2 py-3 text-sm">Nenhuma ata disponível.</p>
|
||||||
|
{:else}
|
||||||
{#each atas as ata (ata._id)}
|
{#each atas as ata (ata._id)}
|
||||||
<div class="mb-2 flex items-center">
|
<label
|
||||||
|
class="hover:bg-base-200/50 flex cursor-pointer items-center gap-3 rounded-md px-2 py-2"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id={`ata-${ata._id}`}
|
class="checkbox checkbox-primary checkbox-sm"
|
||||||
checked={formData.atas.includes(ata._id)}
|
checked={formData.atas.includes(ata._id)}
|
||||||
onchange={() => toggleAtaSelection(ata._id)}
|
onchange={() => toggleAtaSelection(ata._id)}
|
||||||
class="mr-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
aria-label="Vincular ata {ata.numero}"
|
||||||
/>
|
/>
|
||||||
<label for={`ata-${ata._id}`} class="text-sm text-gray-700">
|
<span class="text-sm">{ata.numero} ({ata.numeroSei})</span>
|
||||||
{ata.numero} ({ata.numeroSei})
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
{#if atas.length === 0}
|
|
||||||
<p class="text-sm text-gray-500">Nenhuma ata disponível.</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end">
|
<div class="modal-action">
|
||||||
<button
|
<button type="button" class="btn" onclick={closeModal} disabled={saving}>
|
||||||
type="button"
|
|
||||||
onclick={closeModal}
|
|
||||||
class="mr-2 rounded bg-gray-300 px-4 py-2 font-bold text-gray-800 hover:bg-gray-400"
|
|
||||||
>
|
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="submit" class="btn btn-primary" disabled={saving}>
|
||||||
type="submit"
|
{#if saving}
|
||||||
disabled={saving}
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
class="focus:shadow-outline rounded bg-blue-600 px-4 py-2 font-bold text-white hover:bg-blue-700 focus:outline-none disabled:opacity-50"
|
{/if}
|
||||||
>
|
|
||||||
{saving ? 'Salvando...' : 'Salvar'}
|
{saving ? 'Salvando...' : 'Salvar'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Fechar modal"
|
||||||
|
></button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</main>
|
||||||
|
|||||||
@@ -7,7 +7,16 @@
|
|||||||
import { maskCEP, maskCNPJ, maskPhone, maskUF, onlyDigits } from '$lib/utils/masks';
|
import { maskCEP, maskCNPJ, maskPhone, maskUF, onlyDigits } from '$lib/utils/masks';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
const empresasQuery = useQuery(api.empresas.list, {});
|
let filtroEmpresa = $state('');
|
||||||
|
|
||||||
|
const empresasTotalQuery = useQuery(api.empresas.list, {});
|
||||||
|
const empresasQuery = useQuery(api.empresas.list, () => ({
|
||||||
|
query: filtroEmpresa.trim() || undefined
|
||||||
|
}));
|
||||||
|
|
||||||
|
function limparFiltroEmpresa() {
|
||||||
|
filtroEmpresa = '';
|
||||||
|
}
|
||||||
|
|
||||||
let modalAberto = $state(false);
|
let modalAberto = $state(false);
|
||||||
|
|
||||||
@@ -425,6 +434,32 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100/90 border-base-300 mb-6 border shadow-xl backdrop-blur-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_empresa">
|
||||||
|
<span class="label-text font-semibold">Buscar empresa</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro_empresa"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nome fantasia, razão social ou CNPJ"
|
||||||
|
bind:value={filtroEmpresa}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-3 sm:min-w-max sm:justify-end">
|
||||||
|
<div class="text-base-content/70 text-sm">
|
||||||
|
{empresasQuery.data?.length ?? 0} de {empresasTotalQuery.data?.length ?? 0}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={limparFiltroEmpresa}>Limpar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{#if empresasQuery.isLoading}
|
{#if empresasQuery.isLoading}
|
||||||
@@ -437,11 +472,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if empresasQuery.data && empresasQuery.data.length === 0}
|
{:else if empresasQuery.data && empresasQuery.data.length === 0}
|
||||||
<div class="py-10 text-center">
|
<div class="py-10 text-center">
|
||||||
|
{#if (empresasTotalQuery.data?.length ?? 0) === 0}
|
||||||
<p class="text-base-content/70 mb-4">Nenhuma empresa cadastrada ainda.</p>
|
<p class="text-base-content/70 mb-4">Nenhuma empresa cadastrada ainda.</p>
|
||||||
<button class="btn btn-primary gap-2" type="button" onclick={abrirNovaEmpresa}>
|
<button class="btn btn-primary gap-2" type="button" onclick={abrirNovaEmpresa}>
|
||||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||||
Cadastrar primeira empresa
|
Cadastrar primeira empresa
|
||||||
</button>
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p class="text-base-content/70 mb-2">Nenhum resultado encontrado.</p>
|
||||||
|
<p class="text-base-content/60 mb-4 text-sm">
|
||||||
|
Ajuste ou limpe o filtro para ver empresas.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={limparFiltroEmpresa}>
|
||||||
|
Limpar filtro
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if empresasQuery.data}
|
{:else if empresasQuery.data}
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
|
|||||||
@@ -1,12 +1,124 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import { useQuery } from 'convex-svelte';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import { Eye, Plus } from 'lucide-svelte';
|
import { exportarRelatorioPedidosXLSX } from '$lib/utils/pedidos/relatorioPedidosExcel';
|
||||||
|
import { gerarRelatorioPedidosPDF } from '$lib/utils/pedidos/relatorioPedidosPDF';
|
||||||
|
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
|
||||||
|
import PageHeader from '$lib/components/layout/PageHeader.svelte';
|
||||||
|
import PageShell from '$lib/components/layout/PageShell.svelte';
|
||||||
|
import GlassCard from '$lib/components/ui/GlassCard.svelte';
|
||||||
|
import TableCard from '$lib/components/ui/TableCard.svelte';
|
||||||
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
|
import { endOfDay, startOfDay } from 'date-fns';
|
||||||
|
import { Eye, FileText, Plus } from 'lucide-svelte';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'em_rascunho', label: 'Rascunho' },
|
||||||
|
{ value: 'aguardando_aceite', label: 'Aguardando Aceite' },
|
||||||
|
{ value: 'em_analise', label: 'Em Análise' },
|
||||||
|
{ value: 'precisa_ajustes', label: 'Precisa de Ajustes' },
|
||||||
|
{ value: 'concluido', label: 'Concluído' },
|
||||||
|
{ value: 'cancelado', label: 'Cancelado' }
|
||||||
|
] as const;
|
||||||
|
type PedidoStatus = (typeof statusOptions)[number]['value'];
|
||||||
|
|
||||||
|
// Filtros (cumulativos / backend)
|
||||||
|
let filtroNumeroSei = $state('');
|
||||||
|
let filtroCriadoPor = $state<Id<'usuarios'> | ''>('');
|
||||||
|
let filtroAceitoPor = $state<Id<'funcionarios'> | ''>('');
|
||||||
|
let filtroInicio = $state(''); // yyyy-MM-dd
|
||||||
|
let filtroFim = $state(''); // yyyy-MM-dd
|
||||||
|
let statusSelected = $state<Record<PedidoStatus, boolean>>({
|
||||||
|
em_rascunho: false,
|
||||||
|
aguardando_aceite: false,
|
||||||
|
em_analise: false,
|
||||||
|
precisa_ajustes: false,
|
||||||
|
concluido: false,
|
||||||
|
cancelado: false
|
||||||
|
});
|
||||||
|
|
||||||
|
function getSelectedStatuses(): PedidoStatus[] | undefined {
|
||||||
|
const selected = (Object.entries(statusSelected) as Array<[PedidoStatus, boolean]>)
|
||||||
|
.filter(([, v]) => v)
|
||||||
|
.map(([k]) => k);
|
||||||
|
return selected.length > 0 ? selected : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPeriodoInicio(): number | undefined {
|
||||||
|
if (!filtroInicio) return undefined;
|
||||||
|
return startOfDay(new Date(`${filtroInicio}T00:00:00`)).getTime();
|
||||||
|
}
|
||||||
|
function getPeriodoFim(): number | undefined {
|
||||||
|
if (!filtroFim) return undefined;
|
||||||
|
return endOfDay(new Date(`${filtroFim}T23:59:59`)).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function limparFiltros() {
|
||||||
|
filtroNumeroSei = '';
|
||||||
|
filtroCriadoPor = '';
|
||||||
|
filtroAceitoPor = '';
|
||||||
|
filtroInicio = '';
|
||||||
|
filtroFim = '';
|
||||||
|
(Object.keys(statusSelected) as PedidoStatus[]).forEach((k) => (statusSelected[k] = false));
|
||||||
|
}
|
||||||
|
|
||||||
|
const usuariosQuery = useQuery(api.usuarios.listar, { ativo: true });
|
||||||
|
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||||
|
|
||||||
|
const filtroArgs = () => ({
|
||||||
|
statuses: getSelectedStatuses(),
|
||||||
|
numeroSei: filtroNumeroSei.trim() || undefined,
|
||||||
|
criadoPor: filtroCriadoPor ? filtroCriadoPor : undefined,
|
||||||
|
aceitoPor: filtroAceitoPor ? filtroAceitoPor : undefined,
|
||||||
|
periodoInicio: getPeriodoInicio(),
|
||||||
|
periodoFim: getPeriodoFim()
|
||||||
|
});
|
||||||
|
|
||||||
|
let generatingPDF = $state(false);
|
||||||
|
let generatingXLSX = $state(false);
|
||||||
|
|
||||||
|
async function gerarPDF() {
|
||||||
|
if (!filtroInicio && !filtroFim) {
|
||||||
|
alert('Informe um período (início e/ou fim) para gerar o relatório.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
generatingPDF = true;
|
||||||
|
const relatorio = await client.query(api.pedidos.gerarRelatorio, filtroArgs());
|
||||||
|
gerarRelatorioPedidosPDF(relatorio);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erro ao gerar relatório PDF:', e);
|
||||||
|
alert('Erro ao gerar relatório PDF. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
generatingPDF = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportarXLSX() {
|
||||||
|
if (!filtroInicio && !filtroFim) {
|
||||||
|
alert('Informe um período (início e/ou fim) para exportar o relatório.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
generatingXLSX = true;
|
||||||
|
const relatorio = await client.query(api.pedidos.gerarRelatorio, filtroArgs());
|
||||||
|
await exportarRelatorioPedidosXLSX(relatorio);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erro ao exportar relatório XLSX:', e);
|
||||||
|
alert('Erro ao exportar relatório Excel. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
generatingXLSX = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reactive queries
|
// Reactive queries
|
||||||
const pedidosQuery = useQuery(api.pedidos.list, {});
|
const pedidosQuery = useQuery(api.pedidos.list, filtroArgs);
|
||||||
const myItemsQuery = useQuery(api.pedidos.listByItemCreator, {});
|
const myItemsQuery = useQuery(api.pedidos.listByItemCreator, filtroArgs);
|
||||||
const acoesQuery = useQuery(api.acoes.list, {});
|
const acoesQuery = useQuery(api.acoes.list, {});
|
||||||
|
|
||||||
let activeTab = $state<'all' | 'my_items'>('all');
|
let activeTab = $state<'all' | 'my_items'>('all');
|
||||||
@@ -42,22 +154,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusColor(status: string) {
|
function getStatusBadgeClass(status: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'em_rascunho':
|
case 'em_rascunho':
|
||||||
return 'bg-gray-100 text-gray-800';
|
return 'badge-ghost';
|
||||||
case 'aguardando_aceite':
|
case 'aguardando_aceite':
|
||||||
return 'bg-yellow-100 text-yellow-800';
|
return 'badge-warning';
|
||||||
case 'em_analise':
|
case 'em_analise':
|
||||||
return 'bg-blue-100 text-blue-800';
|
return 'badge-info';
|
||||||
case 'precisa_ajustes':
|
case 'precisa_ajustes':
|
||||||
return 'bg-orange-100 text-orange-800';
|
return 'badge-secondary';
|
||||||
case 'concluido':
|
case 'concluido':
|
||||||
return 'bg-green-100 text-green-800';
|
return 'badge-success';
|
||||||
case 'cancelado':
|
case 'cancelado':
|
||||||
return 'bg-red-100 text-red-800';
|
return 'badge-error';
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-100 text-gray-800';
|
return 'badge-ghost';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,18 +178,151 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container mx-auto p-6">
|
<PageShell>
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<Breadcrumbs items={[{ label: 'Dashboard', href: resolve('/') }, { label: 'Pedidos' }]} />
|
||||||
<h1 class="text-2xl font-bold">Pedidos</h1>
|
|
||||||
|
<PageHeader title="Pedidos" subtitle="Cadastro, acompanhamento e relatórios de pedidos">
|
||||||
|
{#snippet icon()}
|
||||||
|
<FileText strokeWidth={2} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet actions()}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline"
|
||||||
|
onclick={gerarPDF}
|
||||||
|
disabled={generatingPDF || generatingXLSX}
|
||||||
|
title="Gera relatório completo (PDF) no padrão do sistema"
|
||||||
|
>
|
||||||
|
{#if generatingPDF}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{/if}
|
||||||
|
{generatingPDF ? 'Gerando PDF...' : 'Relatório (PDF)'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline"
|
||||||
|
onclick={exportarXLSX}
|
||||||
|
disabled={generatingPDF || generatingXLSX}
|
||||||
|
title="Exporta relatório completo em Excel (XLSX)"
|
||||||
|
>
|
||||||
|
{#if generatingXLSX}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{/if}
|
||||||
|
{generatingXLSX ? 'Exportando...' : 'Excel (XLSX)'}
|
||||||
|
</button>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={resolve('/pedidos/novo')}
|
href={resolve('/pedidos/novo')}
|
||||||
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
|
||||||
>
|
>
|
||||||
<Plus size={20} />
|
<Plus class="h-5 w-5" strokeWidth={2} />
|
||||||
Novo Pedido
|
Novo Pedido
|
||||||
</a>
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<GlassCard class="mb-6">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-5">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_numeroSei">
|
||||||
|
<span class="label-text font-semibold">Número SEI</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro_numeroSei"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
type="text"
|
||||||
|
placeholder="Digite para filtrar..."
|
||||||
|
bind:value={filtroNumeroSei}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_criadoPor">
|
||||||
|
<span class="label-text font-semibold">Criado por</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="filtro_criadoPor"
|
||||||
|
class="select select-bordered focus:select-primary w-full"
|
||||||
|
bind:value={filtroCriadoPor}
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
{#each usuariosQuery.data || [] as u (u._id)}
|
||||||
|
<option value={u._id}>{u.nome}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_aceitoPor">
|
||||||
|
<span class="label-text font-semibold">Aceito por</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="filtro_aceitoPor"
|
||||||
|
class="select select-bordered focus:select-primary w-full"
|
||||||
|
bind:value={filtroAceitoPor}
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
{#each funcionariosQuery.data || [] as f (f._id)}
|
||||||
|
<option value={f._id}>{f.nome}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_inicio">
|
||||||
|
<span class="label-text font-semibold">Período (início)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro_inicio"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
type="date"
|
||||||
|
bind:value={filtroInicio}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="filtro_fim">
|
||||||
|
<span class="label-text font-semibold">Período (fim)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro_fim"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
type="date"
|
||||||
|
bind:value={filtroFim}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full md:col-span-3 lg:col-span-5">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text font-semibold">Status</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
{#each statusOptions as s (s.value)}
|
||||||
|
<label class="label cursor-pointer justify-start gap-2 py-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-primary checkbox-sm"
|
||||||
|
checked={statusSelected[s.value]}
|
||||||
|
onchange={(e) =>
|
||||||
|
(statusSelected[s.value] = (e.currentTarget as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
<span class="label-text">{s.label}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-3 sm:min-w-max sm:justify-end">
|
||||||
|
<div class="text-base-content/70 text-sm">{pedidos.length} resultado(s)</div>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={limparFiltros}>Limpar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
<div role="tablist" class="tabs tabs-bordered mb-6">
|
<div role="tablist" class="tabs tabs-bordered mb-6">
|
||||||
<button
|
<button
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -96,87 +341,82 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex items-center justify-center py-10">
|
||||||
{#each Array(3) as _, i (i)}
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
<div class="skeleton h-16 w-full rounded-lg"></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error">
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-hidden rounded-lg bg-white shadow-md">
|
<TableCard>
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="table-zebra table w-full">
|
||||||
<thead class="bg-gray-50">
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Número SEI</th
|
>Número SEI</th
|
||||||
>
|
>
|
||||||
<th
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Status</th
|
>Status</th
|
||||||
>
|
>
|
||||||
<th
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
>Criado por</th
|
||||||
>Criado Por</th
|
>
|
||||||
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
|
>Data de criação</th
|
||||||
>
|
>
|
||||||
<th
|
<th
|
||||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
|
||||||
>Data de Criação</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
|
||||||
>Ações</th
|
>Ações</th
|
||||||
>
|
>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
<tbody>
|
||||||
|
{#if pedidos.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="py-12 text-center">
|
||||||
|
<div class="text-base-content/60 flex flex-col items-center gap-2">
|
||||||
|
<p class="text-lg font-semibold">Nenhum pedido encontrado</p>
|
||||||
|
<p class="text-sm">Ajuste ou limpe os filtros para ver resultados.</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
{#each pedidos as pedido (pedido._id)}
|
{#each pedidos as pedido (pedido._id)}
|
||||||
<tr class="hover:bg-gray-50">
|
<tr class="hover:bg-base-200/50 transition-colors">
|
||||||
<td class="px-6 py-4 font-medium whitespace-nowrap">
|
<td class="font-medium whitespace-nowrap">
|
||||||
{#if pedido.numeroSei}
|
{#if pedido.numeroSei}
|
||||||
{pedido.numeroSei}
|
{pedido.numeroSei}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-amber-600">Sem número SEI</span>
|
<span class="text-warning">Sem número SEI</span>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="whitespace-nowrap">
|
||||||
<span
|
<span class="badge badge-sm {getStatusBadgeClass(pedido.status)}">
|
||||||
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold {getStatusColor(
|
|
||||||
pedido.status
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{formatStatus(pedido.status)}
|
{formatStatus(pedido.status)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-gray-500">
|
<td class="text-base-content/70 whitespace-nowrap">
|
||||||
{pedido.criadoPorNome || 'Desconhecido'}
|
{pedido.criadoPorNome || 'Desconhecido'}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-gray-500">
|
<td class="text-base-content/70 whitespace-nowrap">
|
||||||
{formatDate(pedido.criadoEm)}
|
{formatDate(pedido.criadoEm)}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
<td class="text-right whitespace-nowrap">
|
||||||
<a
|
<a
|
||||||
href={resolve(`/pedidos/${pedido._id}`)}
|
href={resolve(`/pedidos/${pedido._id}`)}
|
||||||
class="inline-flex items-center gap-1 text-indigo-600 hover:text-indigo-900"
|
class="btn btn-ghost btn-sm gap-2"
|
||||||
|
aria-label="Visualizar pedido"
|
||||||
>
|
>
|
||||||
<Eye size={18} />
|
<Eye class="h-4 w-4" />
|
||||||
Visualizar
|
Visualizar
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{#if pedidos.length === 0}
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="px-6 py-4 text-center text-gray-500"
|
|
||||||
>Nenhum pedido encontrado.</td
|
|
||||||
>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</TableCard>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</PageShell>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,12 @@
|
|||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import { CheckCircle, Clock, FileText, User } from 'lucide-svelte';
|
import { CheckCircle, Clock, FileText, User } from 'lucide-svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
|
||||||
|
import PageHeader from '$lib/components/layout/PageHeader.svelte';
|
||||||
|
import PageShell from '$lib/components/layout/PageShell.svelte';
|
||||||
|
import GlassCard from '$lib/components/ui/GlassCard.svelte';
|
||||||
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
const ordersQuery = useQuery(api.pedidos.listForAcceptance, {});
|
const ordersQuery = useQuery(api.pedidos.listForAcceptance, {});
|
||||||
@@ -27,42 +33,44 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<PageShell>
|
||||||
<div class="flex items-center justify-between">
|
<Breadcrumbs
|
||||||
<div>
|
items={[
|
||||||
<h1 class="text-primary text-2xl font-bold tracking-tight">Pedidos para Aceite</h1>
|
{ label: 'Dashboard', href: resolve('/') },
|
||||||
<p class="text-base-content/70 mt-1">
|
{ label: 'Pedidos', href: resolve('/pedidos') },
|
||||||
Lista de pedidos aguardando análise do setor de compras.
|
{ label: 'Aceite' }
|
||||||
</p>
|
]}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
<PageHeader
|
||||||
|
title="Pedidos para Aceite"
|
||||||
|
subtitle="Lista de pedidos aguardando análise do setor de compras"
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<Clock strokeWidth={2} />
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
{#if ordersQuery.isLoading}
|
{#if ordersQuery.isLoading}
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex items-center justify-center py-10">
|
||||||
{#each Array(3) as _, i (i)}
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
<div class="skeleton h-24 w-full rounded-lg"></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{:else if ordersQuery.error}
|
{:else if ordersQuery.error}
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error">
|
||||||
<span>Erro ao carregar pedidos: {ordersQuery.error.message}</span>
|
<span>Erro ao carregar pedidos: {ordersQuery.error.message}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if !ordersQuery.data || ordersQuery.data.length === 0}
|
{:else if !ordersQuery.data || ordersQuery.data.length === 0}
|
||||||
<div
|
<EmptyState title="Tudo em dia!" description="Não há pedidos aguardando aceite no momento.">
|
||||||
class="bg-base-100 flex flex-col items-center justify-center rounded-lg border py-12 text-center shadow-sm"
|
{#snippet icon()}
|
||||||
>
|
<CheckCircle />
|
||||||
<div class="bg-base-200 mb-4 rounded-full p-4">
|
{/snippet}
|
||||||
<CheckCircle class="text-base-content/30 h-8 w-8" />
|
</EmptyState>
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-medium">Tudo em dia!</h3>
|
|
||||||
<p class="text-base-content/60 mt-1 max-w-sm">Não há pedidos aguardando aceite no momento.</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
{#each ordersQuery.data as pedido (pedido._id)}
|
{#each ordersQuery.data as pedido (pedido._id)}
|
||||||
<div
|
<GlassCard class="border-base-300 hover:border-primary/30 transition-all hover:shadow-md">
|
||||||
class="bg-base-100 border-base-200 hover:border-primary/30 group relative overflow-hidden rounded-xl border p-6 shadow-sm transition-all hover:shadow-md"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -90,7 +98,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a href="/pedidos/{pedido._id}" class="btn btn-ghost btn-sm">
|
<a href={resolve(`/pedidos/${pedido._id}`)} class="btn btn-ghost btn-sm">
|
||||||
<FileText class="mr-2 h-4 w-4" />
|
<FileText class="mr-2 h-4 w-4" />
|
||||||
Ver Detalhes
|
Ver Detalhes
|
||||||
</a>
|
</a>
|
||||||
@@ -108,8 +116,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</GlassCard>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
|||||||
@@ -2,47 +2,53 @@
|
|||||||
import { useQuery } from 'convex-svelte';
|
import { useQuery } from 'convex-svelte';
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import { ClipboardList, Clock, FileText, User, Search } from 'lucide-svelte';
|
import { ClipboardList, Clock, FileText, User, Search } from 'lucide-svelte';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
|
||||||
|
import PageHeader from '$lib/components/layout/PageHeader.svelte';
|
||||||
|
import PageShell from '$lib/components/layout/PageShell.svelte';
|
||||||
|
import GlassCard from '$lib/components/ui/GlassCard.svelte';
|
||||||
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
|
|
||||||
const ordersQuery = useQuery(api.pedidos.listMyAnalysis, {});
|
const ordersQuery = useQuery(api.pedidos.listMyAnalysis, {});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<PageShell>
|
||||||
<div class="flex items-center justify-between">
|
<Breadcrumbs
|
||||||
<div>
|
items={[
|
||||||
<h1 class="text-primary text-2xl font-bold tracking-tight">Minhas Análises</h1>
|
{ label: 'Dashboard', href: resolve('/') },
|
||||||
<p class="text-base-content/70 mt-1">Pedidos que você aceitou e está analisando.</p>
|
{ label: 'Pedidos', href: resolve('/pedidos') },
|
||||||
</div>
|
{ label: 'Minhas análises' }
|
||||||
</div>
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageHeader title="Minhas Análises" subtitle="Pedidos que você aceitou e está analisando">
|
||||||
|
{#snippet icon()}
|
||||||
|
<Search strokeWidth={2} />
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
{#if ordersQuery.isLoading}
|
{#if ordersQuery.isLoading}
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex items-center justify-center py-10">
|
||||||
{#each Array(3) as _, i (i)}
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
<div class="skeleton h-24 w-full rounded-lg"></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{:else if ordersQuery.error}
|
{:else if ordersQuery.error}
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error">
|
||||||
<span>Erro ao carregar análises: {ordersQuery.error.message}</span>
|
<span>Erro ao carregar análises: {ordersQuery.error.message}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if !ordersQuery.data || ordersQuery.data.length === 0}
|
{:else if !ordersQuery.data || ordersQuery.data.length === 0}
|
||||||
<div
|
<EmptyState
|
||||||
class="bg-base-100 flex flex-col items-center justify-center rounded-lg border py-12 text-center shadow-sm"
|
title="Nenhuma análise em andamento"
|
||||||
|
description="Você não possui pedidos sob sua responsabilidade no momento. Vá para "Pedidos para Aceite" para pegar novos pedidos."
|
||||||
>
|
>
|
||||||
<div class="bg-base-200 mb-4 rounded-full p-4">
|
{#snippet icon()}
|
||||||
<ClipboardList class="text-base-content/30 h-8 w-8" />
|
<ClipboardList />
|
||||||
</div>
|
{/snippet}
|
||||||
<h3 class="text-lg font-medium">Nenhuma análise em andamento</h3>
|
</EmptyState>
|
||||||
<p class="text-base-content/60 mt-1 max-w-sm">
|
|
||||||
Você não possui pedidos sob sua responsabilidade no momento. Vá para "Pedidos para Aceite"
|
|
||||||
para pegar novos pedidos.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
{#each ordersQuery.data as pedido (pedido._id)}
|
{#each ordersQuery.data as pedido (pedido._id)}
|
||||||
<div
|
<GlassCard class="border-base-300 hover:border-primary/30 transition-all hover:shadow-md">
|
||||||
class="bg-base-100 border-base-200 hover:border-primary/30 group relative overflow-hidden rounded-xl border p-6 shadow-sm transition-all hover:shadow-md"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -57,7 +63,9 @@
|
|||||||
<h3 class="text-lg font-bold">
|
<h3 class="text-lg font-bold">
|
||||||
{pedido.numeroSei ? `SEI: ${pedido.numeroSei}` : 'Sem número SEI'}
|
{pedido.numeroSei ? `SEI: ${pedido.numeroSei}` : 'Sem número SEI'}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="text-base-content/70 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
<div
|
||||||
|
class="text-base-content/70 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<User class="h-3.5 w-3.5" />
|
<User class="h-3.5 w-3.5" />
|
||||||
<span>Criado por: {pedido.criadoPorNome}</span>
|
<span>Criado por: {pedido.criadoPorNome}</span>
|
||||||
@@ -70,14 +78,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a href="/pedidos/{pedido._id}" class="btn btn-primary btn-sm">
|
<a href={resolve(`/pedidos/${pedido._id}`)} class="btn btn-primary btn-sm">
|
||||||
<FileText class="mr-2 h-4 w-4" />
|
<FileText class="mr-2 h-4 w-4" />
|
||||||
Continuar Análise
|
Continuar Análise
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</GlassCard>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
import { Plus, Trash2, X, Info } from 'lucide-svelte';
|
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
|
||||||
|
import PageHeader from '$lib/components/layout/PageHeader.svelte';
|
||||||
|
import PageShell from '$lib/components/layout/PageShell.svelte';
|
||||||
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
|
import GlassCard from '$lib/components/ui/GlassCard.svelte';
|
||||||
|
import { Info, Plus, Trash2, X } from 'lucide-svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
@@ -25,39 +30,28 @@
|
|||||||
let warning = $state<string | null>(null);
|
let warning = $state<string | null>(null);
|
||||||
|
|
||||||
// Item selection state
|
// Item selection state
|
||||||
|
// Nota: modalidade é opcional aqui pois será definida pelo Setor de Compras posteriormente
|
||||||
type SelectedItem = {
|
type SelectedItem = {
|
||||||
objeto: Doc<'objetos'>;
|
objeto: Doc<'objetos'>;
|
||||||
quantidade: number;
|
quantidade: number;
|
||||||
modalidade: 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
|
|
||||||
acaoId?: Id<'acoes'>;
|
acaoId?: Id<'acoes'>;
|
||||||
ataId?: Id<'atas'>;
|
|
||||||
ataNumero?: string; // For display
|
|
||||||
ata?: Doc<'atas'>; // Full ata object for details
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let selectedItems = $state<SelectedItem[]>([]);
|
let selectedItems = $state<SelectedItem[]>([]);
|
||||||
let selectedObjetoIds = $derived(selectedItems.map((i) => i.objeto._id));
|
let selectedObjetoIds = $derived(selectedItems.map((i) => i.objeto._id));
|
||||||
let hasMixedModalidades = $derived(new Set(selectedItems.map((i) => i.modalidade)).size > 1);
|
|
||||||
|
|
||||||
// Item configuration modal
|
// Item configuration modal
|
||||||
let showItemModal = $state(false);
|
let showItemModal = $state(false);
|
||||||
let itemConfig = $state<{
|
let itemConfig = $state<{
|
||||||
objeto: Doc<'objetos'> | null;
|
objeto: Doc<'objetos'> | null;
|
||||||
quantidade: number;
|
quantidade: number;
|
||||||
modalidade: 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
|
|
||||||
acaoId: string; // using string to handle empty select
|
acaoId: string; // using string to handle empty select
|
||||||
ataId: string; // using string to handle empty select
|
|
||||||
}>({
|
}>({
|
||||||
objeto: null,
|
objeto: null,
|
||||||
quantidade: 1,
|
quantidade: 1,
|
||||||
modalidade: 'consumo',
|
acaoId: ''
|
||||||
acaoId: '',
|
|
||||||
ataId: ''
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let availableAtas = $state<Doc<'atas'>[]>([]);
|
|
||||||
|
|
||||||
// Item Details Modal
|
|
||||||
let showDetailsModal = $state(false);
|
let showDetailsModal = $state(false);
|
||||||
let detailsItem = $state<SelectedItem | null>(null);
|
let detailsItem = $state<SelectedItem | null>(null);
|
||||||
|
|
||||||
@@ -72,16 +66,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openItemModal(objeto: Doc<'objetos'>) {
|
async function openItemModal(objeto: Doc<'objetos'>) {
|
||||||
// Fetch linked Atas for this object
|
|
||||||
const linkedAtas = await client.query(api.objetos.getAtas, { objetoId: objeto._id });
|
|
||||||
availableAtas = linkedAtas;
|
|
||||||
|
|
||||||
itemConfig = {
|
itemConfig = {
|
||||||
objeto,
|
objeto,
|
||||||
quantidade: 1,
|
quantidade: 1,
|
||||||
modalidade: 'consumo',
|
acaoId: ''
|
||||||
acaoId: '',
|
|
||||||
ataId: ''
|
|
||||||
};
|
};
|
||||||
showItemModal = true;
|
showItemModal = true;
|
||||||
searchQuery = ''; // Clear search
|
searchQuery = ''; // Clear search
|
||||||
@@ -90,24 +78,17 @@
|
|||||||
function closeItemModal() {
|
function closeItemModal() {
|
||||||
showItemModal = false;
|
showItemModal = false;
|
||||||
itemConfig.objeto = null;
|
itemConfig.objeto = null;
|
||||||
availableAtas = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmAddItem() {
|
function confirmAddItem() {
|
||||||
if (!itemConfig.objeto) return;
|
if (!itemConfig.objeto) return;
|
||||||
|
|
||||||
const selectedAta = availableAtas.find((a) => a._id === itemConfig.ataId);
|
|
||||||
|
|
||||||
selectedItems = [
|
selectedItems = [
|
||||||
...selectedItems,
|
...selectedItems,
|
||||||
{
|
{
|
||||||
objeto: itemConfig.objeto,
|
objeto: itemConfig.objeto,
|
||||||
quantidade: itemConfig.quantidade,
|
quantidade: itemConfig.quantidade,
|
||||||
modalidade: itemConfig.modalidade,
|
acaoId: itemConfig.acaoId ? (itemConfig.acaoId as Id<'acoes'>) : undefined
|
||||||
acaoId: itemConfig.acaoId ? (itemConfig.acaoId as Id<'acoes'>) : undefined,
|
|
||||||
ataId: itemConfig.ataId ? (itemConfig.ataId as Id<'atas'>) : undefined,
|
|
||||||
ataNumero: selectedAta?.numero,
|
|
||||||
ata: selectedAta
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
checkExisting();
|
checkExisting();
|
||||||
@@ -128,7 +109,6 @@
|
|||||||
criadoEm: number;
|
criadoEm: number;
|
||||||
matchingItems?: {
|
matchingItems?: {
|
||||||
objetoId: Id<'objetos'>;
|
objetoId: Id<'objetos'>;
|
||||||
modalidade: SelectedItem['modalidade'];
|
|
||||||
quantidade: number;
|
quantidade: number;
|
||||||
}[];
|
}[];
|
||||||
}[]
|
}[]
|
||||||
@@ -154,36 +134,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatModalidade(modalidade: SelectedItem['modalidade']) {
|
|
||||||
switch (modalidade) {
|
|
||||||
case 'consumo':
|
|
||||||
return 'Consumo';
|
|
||||||
case 'dispensa':
|
|
||||||
return 'Dispensa';
|
|
||||||
case 'inexgibilidade':
|
|
||||||
return 'Inexigibilidade';
|
|
||||||
case 'adesao':
|
|
||||||
return 'Adesão';
|
|
||||||
default:
|
|
||||||
return modalidade;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModalidadeBadgeClasses(modalidade: SelectedItem['modalidade']) {
|
|
||||||
switch (modalidade) {
|
|
||||||
case 'consumo':
|
|
||||||
return 'bg-blue-100 text-blue-800';
|
|
||||||
case 'dispensa':
|
|
||||||
return 'bg-yellow-100 text-yellow-800';
|
|
||||||
case 'inexgibilidade':
|
|
||||||
return 'bg-purple-100 text-purple-800';
|
|
||||||
case 'adesao':
|
|
||||||
return 'bg-green-100 text-green-800';
|
|
||||||
default:
|
|
||||||
return 'bg-gray-100 text-gray-800';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAcaoNome(acaoId: Id<'acoes'> | undefined) {
|
function getAcaoNome(acaoId: Id<'acoes'> | undefined) {
|
||||||
if (!acaoId) return '-';
|
if (!acaoId) return '-';
|
||||||
const acao = acoes.find((a) => a._id === acaoId);
|
const acao = acoes.find((a) => a._id === acaoId);
|
||||||
@@ -203,8 +153,7 @@
|
|||||||
.map((match) => {
|
.map((match) => {
|
||||||
// Find name from selected items (might be multiple with same object, just pick one name)
|
// Find name from selected items (might be multiple with same object, just pick one name)
|
||||||
const item = selectedItems.find((p) => p.objeto._id === match.objetoId);
|
const item = selectedItems.find((p) => p.objeto._id === match.objetoId);
|
||||||
const modalidadeLabel = formatModalidade(match.modalidade);
|
return `${item?.objeto.nome}: ${match.quantidade} un.`;
|
||||||
return `${item?.objeto.nome} (${modalidadeLabel}): ${match.quantidade} un.`;
|
|
||||||
})
|
})
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
|
||||||
@@ -215,9 +164,7 @@
|
|||||||
if (!pedido.matchingItems || pedido.matchingItems.length === 0) return null;
|
if (!pedido.matchingItems || pedido.matchingItems.length === 0) return null;
|
||||||
|
|
||||||
for (const match of pedido.matchingItems) {
|
for (const match of pedido.matchingItems) {
|
||||||
const item = selectedItems.find(
|
const item = selectedItems.find((p) => p.objeto._id === match.objetoId);
|
||||||
(p) => p.objeto._id === match.objetoId && p.modalidade === match.modalidade
|
|
||||||
);
|
|
||||||
if (item) {
|
if (item) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
@@ -226,27 +173,22 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPedidoHref(pedido: (typeof existingPedidos)[0]) {
|
function buildPedidoHref(pedido: (typeof existingPedidos)[0]): `/pedidos/${string}` {
|
||||||
const matchedItem = getFirstMatchingSelectedItem(pedido);
|
const matchedItem = getFirstMatchingSelectedItem(pedido);
|
||||||
|
|
||||||
if (!matchedItem) {
|
if (!matchedItem) {
|
||||||
return resolve(`/pedidos/${pedido._id}`);
|
return `/pedidos/${pedido._id}` as `/pedidos/${string}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('obj', matchedItem.objeto._id);
|
params.set('obj', matchedItem.objeto._id);
|
||||||
params.set('qtd', String(matchedItem.quantidade));
|
params.set('qtd', String(matchedItem.quantidade));
|
||||||
params.set('mod', matchedItem.modalidade);
|
|
||||||
|
|
||||||
if (matchedItem.acaoId) {
|
if (matchedItem.acaoId) {
|
||||||
params.set('acao', matchedItem.acaoId);
|
params.set('acao', matchedItem.acaoId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedItem.ataId) {
|
return `/pedidos/${pedido._id}?${params.toString()}` as `/pedidos/${string}`;
|
||||||
params.set('ata', matchedItem.ataId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolve(`/pedidos/${pedido._id}?${params.toString()}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkExisting() {
|
async function checkExisting() {
|
||||||
@@ -258,13 +200,11 @@
|
|||||||
|
|
||||||
checking = true;
|
checking = true;
|
||||||
try {
|
try {
|
||||||
// Importante: ação (acaoId) NÃO entra no filtro de similaridade.
|
// Importante: O filtro considera apenas objetoId (modalidade não é mais usada na criação).
|
||||||
// O filtro considera apenas combinação de objeto + modalidade.
|
|
||||||
const itensFiltro =
|
const itensFiltro =
|
||||||
selectedItems.length > 0
|
selectedItems.length > 0
|
||||||
? selectedItems.map((item) => ({
|
? selectedItems.map((item) => ({
|
||||||
objetoId: item.objeto._id,
|
objetoId: item.objeto._id
|
||||||
modalidade: item.modalidade
|
|
||||||
}))
|
}))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -289,11 +229,6 @@
|
|||||||
|
|
||||||
async function handleSubmit(e: Event) {
|
async function handleSubmit(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (hasMixedModalidades) {
|
|
||||||
error =
|
|
||||||
'Não é possível criar o pedido com itens de modalidades diferentes. Ajuste os itens antes de continuar.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
creating = true;
|
creating = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
@@ -309,9 +244,7 @@
|
|||||||
objetoId: item.objeto._id,
|
objetoId: item.objeto._id,
|
||||||
valorEstimado: item.objeto.valorEstimado,
|
valorEstimado: item.objeto.valorEstimado,
|
||||||
quantidade: item.quantidade,
|
quantidade: item.quantidade,
|
||||||
modalidade: item.modalidade,
|
acaoId: item.acaoId
|
||||||
acaoId: item.acaoId,
|
|
||||||
ataId: item.ataId
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -326,71 +259,81 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container mx-auto max-w-4xl p-6">
|
<PageShell class="max-w-4xl">
|
||||||
<h1 class="mb-6 text-3xl font-bold">Novo Pedido</h1>
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: 'Dashboard', href: resolve('/') },
|
||||||
|
{ label: 'Pedidos', href: resolve('/pedidos') },
|
||||||
|
{ label: 'Novo' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageHeader title="Novo Pedido" subtitle="Crie um pedido e adicione objetos.">
|
||||||
|
{#snippet icon()}
|
||||||
|
<Plus class="h-6 w-6" />
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="rounded-lg border border-red-400 bg-red-50 px-4 py-3 text-red-700">
|
<div class="alert alert-error">
|
||||||
<p class="font-semibold">Erro</p>
|
<span>{error}</span>
|
||||||
<p class="text-sm">{error}</p>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form onsubmit={handleSubmit} class="space-y-6">
|
<form onsubmit={handleSubmit} class="space-y-6">
|
||||||
<!-- Section 1: Basic Information -->
|
<GlassCard>
|
||||||
<div class="rounded-lg bg-white p-6 shadow-md">
|
<h2 class="text-lg font-semibold">Informações Básicas</h2>
|
||||||
<h2 class="mb-4 text-lg font-semibold text-gray-800">Informações Básicas</h2>
|
<div class="mt-4">
|
||||||
<div>
|
<label class="label py-0" for="numeroSei">
|
||||||
<label class="mb-2 block text-sm font-medium text-gray-700" for="numeroSei">
|
<span class="label-text font-semibold">Número SEI (Opcional)</span>
|
||||||
Número SEI (Opcional)
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
class="focus:shadow-outline w-full appearance-none rounded-lg border border-gray-300 px-4 py-2.5 leading-tight text-gray-700 shadow-sm transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
|
|
||||||
id="numeroSei"
|
id="numeroSei"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={formData.numeroSei}
|
bind:value={formData.numeroSei}
|
||||||
placeholder="Ex: 12345.000000/2023-00"
|
placeholder="Ex: 12345.000000/2023-00"
|
||||||
onblur={checkExisting}
|
onblur={checkExisting}
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
/>
|
/>
|
||||||
<p class="mt-1.5 text-xs text-gray-500">
|
<p class="text-base-content/60 mt-2 text-xs">
|
||||||
Você pode adicionar o número SEI posteriormente.
|
Você pode adicionar o número SEI posteriormente.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</GlassCard>
|
||||||
|
|
||||||
<!-- Section 2: Add Objects -->
|
<GlassCard>
|
||||||
<div class="rounded-lg bg-white p-6 shadow-md">
|
<h2 class="text-lg font-semibold">Adicionar Objetos ao Pedido</h2>
|
||||||
<h2 class="mb-4 text-lg font-semibold text-gray-800">Adicionar Objetos ao Pedido</h2>
|
|
||||||
|
|
||||||
<div class="relative mb-4">
|
<div class="relative mt-4">
|
||||||
<label class="mb-2 block text-sm font-medium text-gray-700" for="search-objetos">
|
<label class="label py-0" for="search-objetos">
|
||||||
Buscar Objetos
|
<span class="label-text font-semibold">Buscar Objetos</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="search-objetos"
|
id="search-objetos"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Digite o nome do objeto..."
|
placeholder="Digite o nome do objeto..."
|
||||||
class="focus:shadow-outline w-full appearance-none rounded-lg border border-gray-300 px-4 py-2.5 leading-tight text-gray-700 shadow-sm transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
|
class="input input-bordered focus:input-primary w-full"
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if searchQuery.length > 0 && searchResults}
|
{#if searchQuery.length > 0 && searchResults}
|
||||||
<div
|
<div
|
||||||
class="absolute z-10 mt-2 w-full rounded-lg border border-gray-200 bg-white shadow-xl"
|
class="border-base-300 bg-base-100 rounded-box absolute z-20 mt-2 w-full overflow-hidden border shadow"
|
||||||
>
|
>
|
||||||
{#if searchResults.length === 0}
|
{#if searchResults.length === 0}
|
||||||
<div class="p-4 text-sm text-gray-500">Nenhum objeto encontrado.</div>
|
<div class="text-base-content/60 p-4 text-sm">Nenhum objeto encontrado.</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="max-h-64 overflow-y-auto">
|
<ul class="menu max-h-64 overflow-y-auto p-2">
|
||||||
{#each searchResults as objeto (objeto._id)}
|
{#each searchResults as objeto (objeto._id)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-blue-50"
|
class="flex items-center justify-between"
|
||||||
onclick={() => openItemModal(objeto)}
|
onclick={() => openItemModal(objeto)}
|
||||||
>
|
>
|
||||||
<span class="font-medium text-gray-800">{objeto.nome}</span>
|
<span class="font-medium">{objeto.nome}</span>
|
||||||
<Plus size={16} class="text-blue-600" />
|
<Plus class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -400,256 +343,161 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if selectedItems.length > 0}
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<h3 class="mb-3 text-sm font-semibold text-gray-700">
|
{#if selectedItems.length > 0}
|
||||||
|
<h3 class="text-base-content/70 mb-3 text-sm font-semibold">
|
||||||
Itens Selecionados ({selectedItems.length})
|
Itens Selecionados ({selectedItems.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-3">
|
<div class="grid gap-3">
|
||||||
{#each selectedItems as item, index (index)}
|
{#each selectedItems as item, index (index)}
|
||||||
<div
|
<GlassCard class="border-base-300" bodyClass="p-4">
|
||||||
class="rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:shadow-md"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="flex-1 space-y-2">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<p class="font-semibold text-gray-900">{item.objeto.nome}</p>
|
<p class="font-semibold">{item.objeto.nome}</p>
|
||||||
<span
|
|
||||||
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${getModalidadeBadgeClasses(
|
|
||||||
item.modalidade
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
{formatModalidade(item.modalidade)}
|
|
||||||
</span>
|
|
||||||
{#if item.ataNumero}
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800"
|
|
||||||
>
|
|
||||||
Ata {item.ataNumero}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if item.acaoId}
|
{#if item.acaoId}
|
||||||
<span
|
<span class="badge badge-info badge-sm"
|
||||||
class="inline-flex items-center rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-medium text-indigo-800"
|
>Ação: {getAcaoNome(item.acaoId)}</span
|
||||||
>
|
>
|
||||||
Ação: {getAcaoNome(item.acaoId)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-600">
|
<div class="text-base-content/70 mt-1 text-sm">
|
||||||
<span>
|
<span class="font-semibold">Qtd:</span>
|
||||||
<strong>Qtd:</strong>
|
|
||||||
{item.quantidade}
|
{item.quantidade}
|
||||||
{item.objeto.unidade}
|
{item.objeto.unidade}
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg p-2 text-blue-600 transition hover:bg-blue-50"
|
class="btn btn-ghost btn-sm btn-square"
|
||||||
onclick={() => openDetails(item)}
|
onclick={() => openDetails(item)}
|
||||||
aria-label="Ver detalhes"
|
title="Ver detalhes"
|
||||||
>
|
>
|
||||||
<Info size={18} />
|
<Info class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg p-2 text-red-600 transition hover:bg-red-50"
|
class="btn btn-ghost btn-sm btn-square text-error"
|
||||||
onclick={() => removeItem(index)}
|
onclick={() => removeItem(index)}
|
||||||
aria-label="Remover item"
|
title="Remover item"
|
||||||
>
|
>
|
||||||
<Trash2 size={18} />
|
<Trash2 class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</GlassCard>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mt-4 rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
|
<EmptyState
|
||||||
<p class="text-sm text-gray-500">
|
title="Nenhum item adicionado"
|
||||||
Nenhum item adicionado. Use a busca acima para adicionar objetos ao pedido.
|
description="Use a busca acima para adicionar objetos ao pedido."
|
||||||
</p>
|
>
|
||||||
</div>
|
{#snippet icon()}
|
||||||
|
<Plus />
|
||||||
|
{/snippet}
|
||||||
|
</EmptyState>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</GlassCard>
|
||||||
<!-- Warnings Section -->
|
|
||||||
{#if hasMixedModalidades}
|
|
||||||
<div class="mb-3 rounded-lg border border-red-400 bg-red-50 px-4 py-3 text-sm text-red-800">
|
|
||||||
<p class="font-semibold">Modalidades diferentes detectadas</p>
|
|
||||||
<p>
|
|
||||||
Não é possível criar o pedido com itens de modalidades diferentes. Ajuste os itens para
|
|
||||||
usar uma única modalidade.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if warning}
|
{#if warning}
|
||||||
<div
|
<div class={`alert ${existingPedidos.length > 0 ? 'alert-warning' : 'alert-info'}`}>
|
||||||
class="rounded-lg border border-yellow-400 bg-yellow-50 px-4 py-3 text-sm text-yellow-800"
|
<span>{warning}</span>
|
||||||
>
|
|
||||||
<p class="font-semibold">Aviso</p>
|
|
||||||
<p>{warning}</p>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if checking}
|
{#if checking}
|
||||||
<p class="text-sm text-gray-500">Verificando pedidos existentes...</p>
|
<div class="text-base-content/60 flex items-center gap-2 text-sm">
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Verificando pedidos existentes...
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if existingPedidos.length > 0}
|
{#if existingPedidos.length > 0}
|
||||||
<div class="rounded-lg border border-yellow-300 bg-yellow-50 p-4">
|
<GlassCard>
|
||||||
<p class="mb-3 font-semibold text-yellow-900">Pedidos similares encontrados:</p>
|
<h2 class="text-lg font-semibold">Pedidos similares encontrados</h2>
|
||||||
<ul class="space-y-2">
|
<div class="mt-4 space-y-2">
|
||||||
{#each existingPedidos as pedido (pedido._id)}
|
{#each existingPedidos as pedido (pedido._id)}
|
||||||
<li class="flex flex-col rounded-lg bg-white px-4 py-3 shadow-sm">
|
<div
|
||||||
<div class="flex items-center justify-between gap-3">
|
class="border-base-300 bg-base-100 flex items-start justify-between gap-3 rounded-lg border p-4"
|
||||||
<div class="space-y-1">
|
>
|
||||||
<p class="text-sm font-medium text-gray-900">
|
<div class="min-w-0">
|
||||||
|
<p class="font-medium">
|
||||||
Pedido {pedido.numeroSei || 'sem número SEI'} — {formatStatus(pedido.status)}
|
Pedido {pedido.numeroSei || 'sem número SEI'} — {formatStatus(pedido.status)}
|
||||||
</p>
|
</p>
|
||||||
{#if getFirstMatchingSelectedItem(pedido)}
|
|
||||||
{#key pedido._id}
|
|
||||||
{#if getFirstMatchingSelectedItem(pedido)}
|
|
||||||
<span
|
|
||||||
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${getModalidadeBadgeClasses(
|
|
||||||
getFirstMatchingSelectedItem(pedido).modalidade
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
Modalidade:{' '}
|
|
||||||
{formatModalidade(getFirstMatchingSelectedItem(pedido).modalidade)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{/key}
|
|
||||||
{/if}
|
|
||||||
{#if getMatchingInfo(pedido)}
|
{#if getMatchingInfo(pedido)}
|
||||||
<p class="mt-1 text-xs text-blue-700">
|
<p class="text-info mt-1 text-xs">{getMatchingInfo(pedido)}</p>
|
||||||
{getMatchingInfo(pedido)}
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a href={resolve(buildPedidoHref(pedido))} class="btn btn-ghost btn-sm">Abrir</a>
|
||||||
href={buildPedidoHref(pedido)}
|
|
||||||
class="text-sm font-medium text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
Abrir
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
</GlassCard>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<div class="flex items-center justify-end gap-3">
|
||||||
<div class="flex items-center justify-end gap-3 border-t pt-6">
|
<a href={resolve('/pedidos')} class="btn">Cancelar</a>
|
||||||
<a
|
|
||||||
href={resolve('/pedidos')}
|
|
||||||
class="rounded-lg bg-gray-200 px-6 py-2.5 font-semibold text-gray-800 transition hover:bg-gray-300"
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</a>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={creating || selectedItems.length === 0 || hasMixedModalidades}
|
disabled={creating || selectedItems.length === 0}
|
||||||
class="rounded-lg bg-blue-600 px-6 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
class="btn btn-primary"
|
||||||
>
|
>
|
||||||
{creating ? 'Criando...' : 'Criar Pedido'}
|
{#if creating}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{/if}
|
||||||
|
Criar Pedido
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Item Configuration Modal -->
|
|
||||||
{#if showItemModal && itemConfig.objeto}
|
{#if showItemModal && itemConfig.objeto}
|
||||||
<div
|
<div class="modal modal-open">
|
||||||
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40 p-4"
|
<div class="modal-box max-w-lg">
|
||||||
>
|
|
||||||
<div class="relative w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-circle absolute top-2 right-2"
|
||||||
onclick={closeItemModal}
|
onclick={closeItemModal}
|
||||||
class="absolute top-4 right-4 rounded-lg p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
aria-label="Fechar modal"
|
||||||
aria-label="Fechar"
|
|
||||||
>
|
>
|
||||||
<X size={24} />
|
<X class="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h3 class="mb-4 text-xl font-bold text-gray-900">Configurar Item</h3>
|
<h3 class="text-lg font-bold">Configurar Item</h3>
|
||||||
|
|
||||||
<div class="mb-6 rounded-lg bg-blue-50 p-4">
|
<div class="border-base-300 bg-base-200/30 mt-4 rounded-lg border p-4">
|
||||||
<p class="font-semibold text-gray-900">{itemConfig.objeto.nome}</p>
|
<p class="font-semibold">{itemConfig.objeto.nome}</p>
|
||||||
<p class="text-sm text-gray-600">Unidade: {itemConfig.objeto.unidade}</p>
|
<p class="text-base-content/70 text-sm">Unidade: {itemConfig.objeto.unidade}</p>
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
<p class="text-base-content/60 mt-1 text-xs">
|
||||||
Valor estimado: {itemConfig.objeto.valorEstimado}
|
Valor estimado: {itemConfig.objeto.valorEstimado}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="mt-4 grid gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-2 block text-sm font-medium text-gray-700" for="quantidade">
|
<label class="label py-0" for="quantidade">
|
||||||
Quantidade
|
<span class="label-text font-semibold">Quantidade</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="quantidade"
|
id="quantidade"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
|
class="input input-bordered focus:input-primary w-full"
|
||||||
bind:value={itemConfig.quantidade}
|
bind:value={itemConfig.quantidade}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-2 block text-sm font-medium text-gray-700" for="modalidade">
|
<label class="label py-0" for="itemAcao">
|
||||||
Modalidade
|
<span class="label-text font-semibold">Ação (Opcional)</span>
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="modalidade"
|
|
||||||
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
|
|
||||||
bind:value={itemConfig.modalidade}
|
|
||||||
>
|
|
||||||
<option value="consumo">Consumo</option>
|
|
||||||
<option value="dispensa">Dispensa</option>
|
|
||||||
<option value="inexgibilidade">Inexigibilidade</option>
|
|
||||||
<option value="adesao">Adesão</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if availableAtas.length > 0}
|
|
||||||
<div class="rounded-lg border-2 border-green-200 bg-green-50 p-4">
|
|
||||||
<div class="mb-2 flex items-center gap-2">
|
|
||||||
<span class="rounded-full bg-green-600 px-2 py-0.5 text-xs font-bold text-white">
|
|
||||||
{availableAtas.length}
|
|
||||||
{availableAtas.length === 1 ? 'Ata' : 'Atas'}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm font-semibold text-green-900">disponível para este objeto</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<label class="mb-2 block text-sm font-medium text-gray-700" for="itemAta">
|
|
||||||
Selecionar Ata (Opcional)
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="itemAta"
|
|
||||||
class="w-full rounded-lg border border-green-300 bg-white px-4 py-2.5 transition focus:border-green-500 focus:ring-2 focus:ring-green-200 focus:outline-none"
|
|
||||||
bind:value={itemConfig.ataId}
|
|
||||||
>
|
|
||||||
<option value="">Nenhuma</option>
|
|
||||||
{#each availableAtas as ata (ata._id)}
|
|
||||||
<option value={ata._id}>Ata {ata.numero} (SEI: {ata.numeroSei})</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="mb-2 block text-sm font-medium text-gray-700" for="itemAcao">
|
|
||||||
Ação (Opcional)
|
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="itemAcao"
|
id="itemAcao"
|
||||||
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
|
class="select select-bordered focus:select-primary w-full"
|
||||||
bind:value={itemConfig.acaoId}
|
bind:value={itemConfig.acaoId}
|
||||||
>
|
>
|
||||||
<option value="">Selecione uma ação...</option>
|
<option value="">Selecione uma ação...</option>
|
||||||
@@ -660,97 +508,75 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn" onclick={closeItemModal}>Cancelar</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick={confirmAddItem}
|
||||||
|
>Adicionar Item</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
class="modal-backdrop"
|
||||||
onclick={closeItemModal}
|
onclick={closeItemModal}
|
||||||
class="rounded-lg bg-gray-200 px-5 py-2.5 font-semibold text-gray-800 transition hover:bg-gray-300"
|
aria-label="Fechar modal"
|
||||||
>
|
></button>
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={confirmAddItem}
|
|
||||||
class="rounded-lg bg-blue-600 px-5 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Adicionar Item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Details Modal -->
|
|
||||||
{#if showDetailsModal && detailsItem}
|
{#if showDetailsModal && detailsItem}
|
||||||
<div
|
<div class="modal modal-open">
|
||||||
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40 p-4"
|
<div class="modal-box max-w-lg">
|
||||||
>
|
|
||||||
<div class="relative w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-circle absolute top-2 right-2"
|
||||||
onclick={closeDetails}
|
onclick={closeDetails}
|
||||||
class="absolute top-4 right-4 rounded-lg p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
aria-label="Fechar modal"
|
||||||
aria-label="Fechar"
|
|
||||||
>
|
>
|
||||||
<X size={24} />
|
<X class="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h3 class="mb-4 text-xl font-bold text-gray-900">Detalhes do Item</h3>
|
<h3 class="text-lg font-bold">Detalhes do Item</h3>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="mt-4 space-y-4">
|
||||||
<div class="rounded-lg bg-gray-50 p-4">
|
<div class="border-base-300 bg-base-200/30 rounded-lg border p-4">
|
||||||
<h4 class="mb-2 font-semibold text-gray-800">Objeto</h4>
|
<h4 class="font-semibold">Objeto</h4>
|
||||||
<p class="text-gray-700"><strong>Nome:</strong> {detailsItem.objeto.nome}</p>
|
<p class="text-base-content/70 mt-2 text-sm">
|
||||||
<p class="text-gray-700"><strong>Unidade:</strong> {detailsItem.objeto.unidade}</p>
|
<strong>Nome:</strong>
|
||||||
<p class="text-gray-700">
|
{detailsItem.objeto.nome}
|
||||||
|
</p>
|
||||||
|
<p class="text-base-content/70 text-sm">
|
||||||
|
<strong>Unidade:</strong>
|
||||||
|
{detailsItem.objeto.unidade}
|
||||||
|
</p>
|
||||||
|
<p class="text-base-content/70 text-sm">
|
||||||
<strong>Valor Estimado:</strong>
|
<strong>Valor Estimado:</strong>
|
||||||
{detailsItem.objeto.valorEstimado}
|
{detailsItem.objeto.valorEstimado}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg bg-gray-50 p-4">
|
<div class="border-base-300 bg-base-200/30 rounded-lg border p-4">
|
||||||
<h4 class="mb-2 font-semibold text-gray-800">Pedido</h4>
|
<h4 class="font-semibold">Pedido</h4>
|
||||||
<p class="text-gray-700"><strong>Quantidade:</strong> {detailsItem.quantidade}</p>
|
<p class="text-base-content/70 mt-2 text-sm">
|
||||||
<p class="text-gray-700"><strong>Modalidade:</strong> {detailsItem.modalidade}</p>
|
<strong>Quantidade:</strong>
|
||||||
|
{detailsItem.quantidade}
|
||||||
|
</p>
|
||||||
|
|
||||||
{#if detailsItem.acaoId}
|
{#if detailsItem.acaoId}
|
||||||
<p class="text-gray-700">
|
<p class="text-base-content/70 text-sm">
|
||||||
<strong>Ação:</strong>
|
<strong>Ação:</strong>
|
||||||
{getAcaoNome(detailsItem.acaoId)}
|
{getAcaoNome(detailsItem.acaoId)}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if detailsItem.ata}
|
|
||||||
<div class="rounded-lg border border-green-100 bg-green-50 p-4">
|
|
||||||
<h4 class="mb-2 font-semibold text-green-900">Ata de Registro de Preços</h4>
|
|
||||||
<p class="text-green-800"><strong>Número:</strong> {detailsItem.ata.numero}</p>
|
|
||||||
<p class="text-green-800">
|
|
||||||
<strong>Processo SEI:</strong>
|
|
||||||
{detailsItem.ata.numeroSei}
|
|
||||||
</p>
|
|
||||||
{#if detailsItem.ata.dataInicio}
|
|
||||||
<p class="text-green-800">
|
|
||||||
<strong>Vigência:</strong>
|
|
||||||
{detailsItem.ata.dataInicio} até {detailsItem.ata.dataFim || 'Indefinido'}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="rounded-lg bg-gray-50 p-4">
|
|
||||||
<p class="text-sm text-gray-500 italic">Nenhuma Ata vinculada a este item.</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="modal-action">
|
||||||
<button
|
<button type="button" class="btn" onclick={closeDetails}>Fechar</button>
|
||||||
type="button"
|
|
||||||
onclick={closeDetails}
|
|
||||||
class="rounded-lg bg-blue-600 px-5 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Fechar
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="modal-backdrop" onclick={closeDetails} aria-label="Fechar modal"
|
||||||
|
></button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</PageShell>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const load = async ({ parent }) => {
|
||||||
|
await parent();
|
||||||
|
};
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
|
||||||
|
import PageHeader from '$lib/components/layout/PageHeader.svelte';
|
||||||
|
import PageShell from '$lib/components/layout/PageShell.svelte';
|
||||||
|
import TableCard from '$lib/components/ui/TableCard.svelte';
|
||||||
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
|
import { ClipboardList, Eye, Plus, X } from 'lucide-svelte';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
const planejamentosQuery = useQuery(api.planejamentos.list, {});
|
||||||
|
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||||
|
const acoesQuery = useQuery(api.acoes.list, {});
|
||||||
|
|
||||||
|
let planejamentos = $derived(planejamentosQuery.data || []);
|
||||||
|
|
||||||
|
function formatStatus(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'rascunho':
|
||||||
|
return 'Rascunho';
|
||||||
|
case 'gerado':
|
||||||
|
return 'Gerado';
|
||||||
|
case 'cancelado':
|
||||||
|
return 'Cancelado';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadgeClass(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'rascunho':
|
||||||
|
return 'badge-ghost';
|
||||||
|
case 'gerado':
|
||||||
|
return 'badge-success';
|
||||||
|
case 'cancelado':
|
||||||
|
return 'badge-error';
|
||||||
|
default:
|
||||||
|
return 'badge-ghost';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateYMD(ymd: string) {
|
||||||
|
// ymd: yyyy-MM-dd
|
||||||
|
const [y, m, d] = ymd.split('-');
|
||||||
|
if (!y || !m || !d) return ymd;
|
||||||
|
return `${d}/${m}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create modal
|
||||||
|
let showCreate = $state(false);
|
||||||
|
let creating = $state(false);
|
||||||
|
let form = $state({
|
||||||
|
titulo: '',
|
||||||
|
descricao: '',
|
||||||
|
data: '',
|
||||||
|
responsavelId: '' as string,
|
||||||
|
acaoId: '' as string
|
||||||
|
});
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
form = { titulo: '', descricao: '', data: '', responsavelId: '', acaoId: '' };
|
||||||
|
showCreate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreate() {
|
||||||
|
showCreate = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!form.titulo.trim()) return toast.error('Informe um título.');
|
||||||
|
if (!form.descricao.trim()) return toast.error('Informe uma descrição.');
|
||||||
|
if (!form.data.trim()) return toast.error('Informe uma data.');
|
||||||
|
if (!form.responsavelId) return toast.error('Selecione um responsável.');
|
||||||
|
|
||||||
|
creating = true;
|
||||||
|
try {
|
||||||
|
const id = await client.mutation(api.planejamentos.create, {
|
||||||
|
titulo: form.titulo,
|
||||||
|
descricao: form.descricao,
|
||||||
|
data: form.data,
|
||||||
|
responsavelId: form.responsavelId as Id<'funcionarios'>,
|
||||||
|
acaoId: form.acaoId ? (form.acaoId as Id<'acoes'>) : undefined
|
||||||
|
});
|
||||||
|
toast.success('Planejamento criado.');
|
||||||
|
showCreate = false;
|
||||||
|
await goto(resolve(`/pedidos/planejamento/${id}`));
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageShell>
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: 'Dashboard', href: resolve('/') },
|
||||||
|
{ label: 'Pedidos', href: resolve('/pedidos') },
|
||||||
|
{ label: 'Planejamento' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="Planejamento de Pedidos"
|
||||||
|
subtitle="Crie e acompanhe planejamentos antes de gerar pedidos"
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<ClipboardList strokeWidth={2} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet actions()}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
|
||||||
|
onclick={openCreate}
|
||||||
|
>
|
||||||
|
<Plus class="h-5 w-5" strokeWidth={2} />
|
||||||
|
Novo planejamento
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{#if planejamentosQuery.isLoading}
|
||||||
|
<div class="flex items-center justify-center py-10">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
{:else if planejamentosQuery.error}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span>{planejamentosQuery.error.message}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<TableCard>
|
||||||
|
<table class="table-zebra table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
|
>Título</th
|
||||||
|
>
|
||||||
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
|
>Data</th
|
||||||
|
>
|
||||||
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
|
>Responsável</th
|
||||||
|
>
|
||||||
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
|
>Ação</th
|
||||||
|
>
|
||||||
|
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
|
>Status</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
|
||||||
|
>Ações</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#if planejamentos.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="py-12 text-center">
|
||||||
|
<div class="text-base-content/60 flex flex-col items-center gap-2">
|
||||||
|
<p class="text-lg font-semibold">Nenhum planejamento encontrado</p>
|
||||||
|
<p class="text-sm">Clique em “Novo planejamento” para criar o primeiro.</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
{#each planejamentos as p (p._id)}
|
||||||
|
<tr class="hover:bg-base-200/50 transition-colors">
|
||||||
|
<td class="font-medium whitespace-nowrap">{p.titulo}</td>
|
||||||
|
<td class="text-base-content/70 whitespace-nowrap">{formatDateYMD(p.data)}</td>
|
||||||
|
<td class="text-base-content/70 whitespace-nowrap">{p.responsavelNome}</td>
|
||||||
|
<td class="text-base-content/70 whitespace-nowrap">{p.acaoNome || '-'}</td>
|
||||||
|
<td class="whitespace-nowrap">
|
||||||
|
<span class="badge badge-sm {getStatusBadgeClass(p.status)}">
|
||||||
|
{formatStatus(p.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right whitespace-nowrap">
|
||||||
|
<a
|
||||||
|
href={resolve(`/pedidos/planejamento/${p._id}`)}
|
||||||
|
class="btn btn-ghost btn-sm gap-2"
|
||||||
|
aria-label="Abrir planejamento"
|
||||||
|
>
|
||||||
|
<Eye class="h-4 w-4" />
|
||||||
|
Abrir
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</TableCard>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showCreate}
|
||||||
|
<div class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-circle absolute top-2 right-2"
|
||||||
|
onclick={closeCreate}
|
||||||
|
aria-label="Fechar modal"
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-bold">Novo planejamento</h3>
|
||||||
|
|
||||||
|
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control w-full md:col-span-2">
|
||||||
|
<label class="label" for="titulo">
|
||||||
|
<span class="label-text font-semibold">Título</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="titulo"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
bind:value={form.titulo}
|
||||||
|
disabled={creating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full md:col-span-2">
|
||||||
|
<label class="label" for="descricao">
|
||||||
|
<span class="label-text font-semibold">Descrição</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="descricao"
|
||||||
|
class="textarea textarea-bordered focus:textarea-primary w-full"
|
||||||
|
rows="4"
|
||||||
|
bind:value={form.descricao}
|
||||||
|
disabled={creating}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="data">
|
||||||
|
<span class="label-text font-semibold">Data</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="data"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
bind:value={form.data}
|
||||||
|
disabled={creating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="responsavel">
|
||||||
|
<span class="label-text font-semibold">Responsável</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="responsavel"
|
||||||
|
class="select select-bordered focus:select-primary w-full"
|
||||||
|
bind:value={form.responsavelId}
|
||||||
|
disabled={creating || funcionariosQuery.isLoading}
|
||||||
|
>
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
{#each funcionariosQuery.data || [] as f (f._id)}
|
||||||
|
<option value={f._id}>{f.nome}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full md:col-span-2">
|
||||||
|
<label class="label" for="acao">
|
||||||
|
<span class="label-text font-semibold">Ação (opcional)</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="acao"
|
||||||
|
class="select select-bordered focus:select-primary w-full"
|
||||||
|
bind:value={form.acaoId}
|
||||||
|
disabled={creating || acoesQuery.isLoading}
|
||||||
|
>
|
||||||
|
<option value="">Nenhuma</option>
|
||||||
|
{#each acoesQuery.data || [] as a (a._id)}
|
||||||
|
<option value={a._id}>{a.nome}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn" onclick={closeCreate} disabled={creating}
|
||||||
|
>Cancelar</button
|
||||||
|
>
|
||||||
|
<button type="button" class="btn btn-primary" onclick={handleCreate} disabled={creating}>
|
||||||
|
{#if creating}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{/if}
|
||||||
|
{creating ? 'Criando...' : 'Criar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="modal-backdrop" onclick={closeCreate} aria-label="Fechar modal"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</PageShell>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const load = async ({ parent }) => {
|
||||||
|
await parent();
|
||||||
|
};
|
||||||
@@ -0,0 +1,900 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
|
||||||
|
import PageShell from '$lib/components/layout/PageShell.svelte';
|
||||||
|
import GlassCard from '$lib/components/ui/GlassCard.svelte';
|
||||||
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { Plus, Trash2, X, Save, Edit } from 'lucide-svelte';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
const planejamentoId = $derived(page.params.id as Id<'planejamentosPedidos'>);
|
||||||
|
|
||||||
|
const planejamentoQuery = $derived.by(() =>
|
||||||
|
useQuery(api.planejamentos.get, { id: planejamentoId })
|
||||||
|
);
|
||||||
|
const itemsQuery = $derived.by(() => useQuery(api.planejamentos.listItems, { planejamentoId }));
|
||||||
|
const pedidosQuery = $derived.by(() =>
|
||||||
|
useQuery(api.planejamentos.listPedidos, { planejamentoId })
|
||||||
|
);
|
||||||
|
|
||||||
|
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||||
|
const acoesQuery = useQuery(api.acoes.list, {});
|
||||||
|
|
||||||
|
let planejamento = $derived(planejamentoQuery.data);
|
||||||
|
let items = $derived(itemsQuery.data || []);
|
||||||
|
let pedidosLinks = $derived(pedidosQuery.data || []);
|
||||||
|
|
||||||
|
const isRascunho = $derived(planejamento?.status === 'rascunho');
|
||||||
|
|
||||||
|
function formatStatus(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'rascunho':
|
||||||
|
return 'Rascunho';
|
||||||
|
case 'gerado':
|
||||||
|
return 'Gerado';
|
||||||
|
case 'cancelado':
|
||||||
|
return 'Cancelado';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadgeClass(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'rascunho':
|
||||||
|
return 'badge-ghost';
|
||||||
|
case 'gerado':
|
||||||
|
return 'badge-success';
|
||||||
|
case 'cancelado':
|
||||||
|
return 'badge-error';
|
||||||
|
default:
|
||||||
|
return 'badge-ghost';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPedidoStatus(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'em_rascunho':
|
||||||
|
return 'Rascunho';
|
||||||
|
case 'aguardando_aceite':
|
||||||
|
return 'Aguardando Aceite';
|
||||||
|
case 'em_analise':
|
||||||
|
return 'Em Análise';
|
||||||
|
case 'precisa_ajustes':
|
||||||
|
return 'Precisa de Ajustes';
|
||||||
|
case 'concluido':
|
||||||
|
return 'Concluído';
|
||||||
|
case 'cancelado':
|
||||||
|
return 'Cancelado';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPedidoBadgeClass(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'em_rascunho':
|
||||||
|
return 'badge-ghost';
|
||||||
|
case 'aguardando_aceite':
|
||||||
|
return 'badge-warning';
|
||||||
|
case 'em_analise':
|
||||||
|
return 'badge-info';
|
||||||
|
case 'precisa_ajustes':
|
||||||
|
return 'badge-secondary';
|
||||||
|
case 'concluido':
|
||||||
|
return 'badge-success';
|
||||||
|
case 'cancelado':
|
||||||
|
return 'badge-error';
|
||||||
|
default:
|
||||||
|
return 'badge-ghost';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Header editing ---
|
||||||
|
let editingHeader = $state(false);
|
||||||
|
let headerForm = $state({
|
||||||
|
titulo: '',
|
||||||
|
descricao: '',
|
||||||
|
data: '',
|
||||||
|
responsavelId: '' as string,
|
||||||
|
acaoId: '' as string
|
||||||
|
});
|
||||||
|
let savingHeader = $state(false);
|
||||||
|
|
||||||
|
function syncHeaderFormFromPlanejamento() {
|
||||||
|
if (!planejamento) return;
|
||||||
|
headerForm = {
|
||||||
|
titulo: planejamento.titulo,
|
||||||
|
descricao: planejamento.descricao,
|
||||||
|
data: planejamento.data,
|
||||||
|
responsavelId: planejamento.responsavelId as unknown as string,
|
||||||
|
acaoId: planejamento.acaoId ? (planejamento.acaoId as unknown as string) : ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditHeader() {
|
||||||
|
if (!isRascunho) return;
|
||||||
|
syncHeaderFormFromPlanejamento();
|
||||||
|
editingHeader = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditHeader() {
|
||||||
|
editingHeader = false;
|
||||||
|
syncHeaderFormFromPlanejamento();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHeader() {
|
||||||
|
if (!planejamento) return;
|
||||||
|
if (!headerForm.titulo.trim()) return toast.error('Informe um título.');
|
||||||
|
if (!headerForm.descricao.trim()) return toast.error('Informe uma descrição.');
|
||||||
|
if (!headerForm.data.trim()) return toast.error('Informe uma data.');
|
||||||
|
if (!headerForm.responsavelId) return toast.error('Selecione um responsável.');
|
||||||
|
|
||||||
|
savingHeader = true;
|
||||||
|
try {
|
||||||
|
await client.mutation(api.planejamentos.update, {
|
||||||
|
id: planejamentoId,
|
||||||
|
titulo: headerForm.titulo,
|
||||||
|
descricao: headerForm.descricao,
|
||||||
|
data: headerForm.data,
|
||||||
|
responsavelId: headerForm.responsavelId as Id<'funcionarios'>,
|
||||||
|
acaoId: headerForm.acaoId ? (headerForm.acaoId as Id<'acoes'>) : null
|
||||||
|
});
|
||||||
|
toast.success('Planejamento atualizado.');
|
||||||
|
editingHeader = false;
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
savingHeader = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Add item (search + modal) ---
|
||||||
|
let searchQuery = $state('');
|
||||||
|
const searchResultsQuery = useQuery(api.objetos.search, () => ({ query: searchQuery }));
|
||||||
|
let searchResults = $derived(searchResultsQuery.data || []);
|
||||||
|
|
||||||
|
type SelectedObjeto = Doc<'objetos'>;
|
||||||
|
let showAddItemModal = $state(false);
|
||||||
|
let addItemConfig = $state<{
|
||||||
|
objeto: SelectedObjeto | null;
|
||||||
|
quantidade: number;
|
||||||
|
valorEstimado: string;
|
||||||
|
numeroDfd: string;
|
||||||
|
}>({
|
||||||
|
objeto: null,
|
||||||
|
quantidade: 1,
|
||||||
|
valorEstimado: '',
|
||||||
|
numeroDfd: ''
|
||||||
|
});
|
||||||
|
let addingItem = $state(false);
|
||||||
|
|
||||||
|
function openAddItemModal(objeto: SelectedObjeto) {
|
||||||
|
addItemConfig = {
|
||||||
|
objeto,
|
||||||
|
quantidade: 1,
|
||||||
|
valorEstimado: objeto.valorEstimado,
|
||||||
|
numeroDfd: ''
|
||||||
|
};
|
||||||
|
showAddItemModal = true;
|
||||||
|
searchQuery = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddItemModal() {
|
||||||
|
showAddItemModal = false;
|
||||||
|
addItemConfig.objeto = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAddItem() {
|
||||||
|
if (!addItemConfig.objeto) return;
|
||||||
|
if (!isRascunho)
|
||||||
|
return toast.error('Não é possível adicionar itens em um planejamento não rascunho.');
|
||||||
|
if (!Number.isFinite(addItemConfig.quantidade) || addItemConfig.quantidade <= 0) {
|
||||||
|
return toast.error('Quantidade inválida.');
|
||||||
|
}
|
||||||
|
if (!addItemConfig.valorEstimado.trim()) return toast.error('Informe o valor estimado.');
|
||||||
|
|
||||||
|
addingItem = true;
|
||||||
|
try {
|
||||||
|
await client.mutation(api.planejamentos.addItem, {
|
||||||
|
planejamentoId,
|
||||||
|
objetoId: addItemConfig.objeto._id,
|
||||||
|
quantidade: addItemConfig.quantidade,
|
||||||
|
valorEstimado: addItemConfig.valorEstimado,
|
||||||
|
numeroDfd: addItemConfig.numeroDfd.trim() || undefined
|
||||||
|
});
|
||||||
|
toast.success('Item adicionado.');
|
||||||
|
closeAddItemModal();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
addingItem = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Inline item edits ---
|
||||||
|
async function updateItemField(
|
||||||
|
itemId: Id<'planejamentoItens'>,
|
||||||
|
patch: { numeroDfd?: string | null; quantidade?: number; valorEstimado?: string }
|
||||||
|
) {
|
||||||
|
if (!isRascunho) return;
|
||||||
|
try {
|
||||||
|
await client.mutation(api.planejamentos.updateItem, { itemId, ...patch });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeItem(itemId: Id<'planejamentoItens'>) {
|
||||||
|
if (!isRascunho) return;
|
||||||
|
try {
|
||||||
|
await client.mutation(api.planejamentos.removeItem, { itemId });
|
||||||
|
toast.success('Item removido.');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Grouping ---
|
||||||
|
let pedidosByDfd = $derived.by(() => {
|
||||||
|
const map: Record<string, (typeof pedidosLinks)[number]> = {};
|
||||||
|
for (const p of pedidosLinks) map[p.numeroDfd] = p;
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
let grouped = $derived.by(() => {
|
||||||
|
const groups: Record<string, typeof items> = {};
|
||||||
|
const semDfd: typeof items = [];
|
||||||
|
|
||||||
|
for (const it of items) {
|
||||||
|
const dfd = (it.numeroDfd || '').trim();
|
||||||
|
if (!dfd) {
|
||||||
|
semDfd.push(it);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!groups[dfd]) groups[dfd] = [];
|
||||||
|
groups[dfd].push(it);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: Array<{ key: string; label: string; items: typeof items; isSemDfd: boolean }> = [];
|
||||||
|
if (semDfd.length > 0)
|
||||||
|
out.push({ key: '__sem_dfd__', label: 'Sem DFD', items: semDfd, isSemDfd: true });
|
||||||
|
|
||||||
|
const keys = Object.keys(groups).sort((a, b) => a.localeCompare(b));
|
||||||
|
for (const k of keys) {
|
||||||
|
out.push({ key: k, label: `DFD ${k}`, items: groups[k], isSemDfd: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
let itensSemDfdCount = $derived(grouped.find((g) => g.isSemDfd)?.items.length ?? 0);
|
||||||
|
let dfdsParaGerar = $derived.by(() => grouped.filter((g) => !g.isSemDfd).map((g) => g.key));
|
||||||
|
|
||||||
|
// --- Gerar pedidos modal ---
|
||||||
|
let showGerarModal = $state(false);
|
||||||
|
let seiByDfd = $state<Record<string, string>>({});
|
||||||
|
let gerando = $state(false);
|
||||||
|
|
||||||
|
const canGerar = $derived(
|
||||||
|
isRascunho && items.length > 0 && itensSemDfdCount === 0 && dfdsParaGerar.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
function openGerarModal() {
|
||||||
|
if (!canGerar) {
|
||||||
|
if (items.length === 0) toast.error('Adicione itens antes de gerar pedidos.');
|
||||||
|
else if (itensSemDfdCount > 0) toast.error('Atribua um DFD a todos os itens antes de gerar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
for (const dfd of dfdsParaGerar) initial[dfd] = '';
|
||||||
|
seiByDfd = initial;
|
||||||
|
showGerarModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGerarModal() {
|
||||||
|
showGerarModal = false;
|
||||||
|
seiByDfd = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmarGeracao() {
|
||||||
|
if (!canGerar) return;
|
||||||
|
for (const dfd of dfdsParaGerar) {
|
||||||
|
if (!seiByDfd[dfd]?.trim()) {
|
||||||
|
return toast.error(`Informe o número SEI para o DFD ${dfd}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gerando = true;
|
||||||
|
try {
|
||||||
|
await client.mutation(api.planejamentos.gerarPedidosPorDfd, {
|
||||||
|
planejamentoId,
|
||||||
|
dfds: dfdsParaGerar.map((dfd) => ({ numeroDfd: dfd, numeroSei: seiByDfd[dfd] }))
|
||||||
|
});
|
||||||
|
toast.success('Pedidos gerados.');
|
||||||
|
closeGerarModal();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
gerando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageShell>
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: 'Dashboard', href: resolve('/') },
|
||||||
|
{ label: 'Pedidos', href: resolve('/pedidos') },
|
||||||
|
{ label: 'Planejamento', href: resolve('/pedidos/planejamento') },
|
||||||
|
{ label: planejamento?.titulo ?? 'Detalhes' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if planejamentoQuery.isLoading}
|
||||||
|
<div class="flex items-center justify-center py-10">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
{:else if planejamentoQuery.error}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span>{planejamentoQuery.error.message}</span>
|
||||||
|
</div>
|
||||||
|
{:else if planejamento}
|
||||||
|
<GlassCard class="mb-6">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h1 class="text-primary truncate text-2xl font-bold">{planejamento.titulo}</h1>
|
||||||
|
<span class="badge badge-sm {getStatusBadgeClass(planejamento.status)}">
|
||||||
|
{formatStatus(planejamento.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-base-content/70 mt-2 text-sm whitespace-pre-wrap">
|
||||||
|
{planejamento.descricao}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-3 md:grid-cols-3">
|
||||||
|
<div class="bg-base-200/50 rounded-lg p-3">
|
||||||
|
<div class="text-base-content/60 text-xs font-semibold">Data</div>
|
||||||
|
<div class="text-base-content text-sm">{planejamento.data}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-base-200/50 rounded-lg p-3">
|
||||||
|
<div class="text-base-content/60 text-xs font-semibold">Responsável</div>
|
||||||
|
<div class="text-base-content text-sm">{planejamento.responsavelNome}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-base-200/50 rounded-lg p-3">
|
||||||
|
<div class="text-base-content/60 text-xs font-semibold">Ação</div>
|
||||||
|
<div class="text-base-content text-sm">{planejamento.acaoNome || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex shrink-0 flex-col items-end gap-2">
|
||||||
|
{#if isRascunho}
|
||||||
|
{#if editingHeader}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm gap-2"
|
||||||
|
onclick={saveHeader}
|
||||||
|
disabled={savingHeader}
|
||||||
|
>
|
||||||
|
{#if savingHeader}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{:else}
|
||||||
|
<Save class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
Salvar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
onclick={cancelEditHeader}
|
||||||
|
disabled={savingHeader}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm gap-2" onclick={startEditHeader}>
|
||||||
|
<Edit class="h-4 w-4" />
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if editingHeader}
|
||||||
|
<div class="border-base-300 bg-base-100 mt-5 rounded-lg border p-4">
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="label" for="eh_titulo">
|
||||||
|
<span class="label-text font-semibold">Título</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="eh_titulo"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
bind:value={headerForm.titulo}
|
||||||
|
disabled={savingHeader}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="label" for="eh_descricao">
|
||||||
|
<span class="label-text font-semibold">Descrição</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="eh_descricao"
|
||||||
|
rows="4"
|
||||||
|
class="textarea textarea-bordered focus:textarea-primary w-full"
|
||||||
|
bind:value={headerForm.descricao}
|
||||||
|
disabled={savingHeader}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label" for="eh_data">
|
||||||
|
<span class="label-text font-semibold">Data</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="eh_data"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
bind:value={headerForm.data}
|
||||||
|
disabled={savingHeader}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label" for="eh_resp">
|
||||||
|
<span class="label-text font-semibold">Responsável</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="eh_resp"
|
||||||
|
class="select select-bordered focus:select-primary w-full"
|
||||||
|
bind:value={headerForm.responsavelId}
|
||||||
|
disabled={savingHeader || funcionariosQuery.isLoading}
|
||||||
|
>
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
{#each funcionariosQuery.data || [] as f (f._id)}
|
||||||
|
<option value={f._id}>{f.nome}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="label" for="eh_acao">
|
||||||
|
<span class="label-text font-semibold">Ação (opcional)</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="eh_acao"
|
||||||
|
class="select select-bordered focus:select-primary w-full"
|
||||||
|
bind:value={headerForm.acaoId}
|
||||||
|
disabled={savingHeader || acoesQuery.isLoading}
|
||||||
|
>
|
||||||
|
<option value="">Nenhuma</option>
|
||||||
|
{#each acoesQuery.data || [] as a (a._id)}
|
||||||
|
<option value={a._id}>{a.nome}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<!-- Itens -->
|
||||||
|
<GlassCard class="mb-6" bodyClass="p-0">
|
||||||
|
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Itens</h2>
|
||||||
|
{#if itensSemDfdCount > 0}
|
||||||
|
<p class="text-warning mt-1 text-xs">
|
||||||
|
{itensSemDfdCount} item(ns) sem DFD. Para gerar pedidos, todos os itens precisam ter DFD.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if isRascunho}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={openGerarModal}
|
||||||
|
disabled={!canGerar}
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
Gerar pedidos
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if isRascunho}
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered focus:input-primary input-sm w-64"
|
||||||
|
placeholder="Buscar objetos..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
{#if searchQuery.length > 0}
|
||||||
|
<div
|
||||||
|
class="border-base-300 bg-base-100 absolute z-10 mt-2 w-full rounded-lg border shadow-xl"
|
||||||
|
>
|
||||||
|
{#if searchResultsQuery.isLoading}
|
||||||
|
<div class="text-base-content/60 p-3 text-sm">Buscando...</div>
|
||||||
|
{:else if searchResults.length === 0}
|
||||||
|
<div class="text-base-content/60 p-3 text-sm">Nenhum objeto encontrado.</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="max-h-64 overflow-y-auto">
|
||||||
|
{#each searchResults as o (o._id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hover:bg-base-200/50 flex w-full items-center justify-between px-4 py-3 text-left transition-colors"
|
||||||
|
onclick={() => openAddItemModal(o)}
|
||||||
|
>
|
||||||
|
<span class="font-medium">{o.nome}</span>
|
||||||
|
<Plus class="text-primary h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
{#if itemsQuery.isLoading}
|
||||||
|
<p class="text-base-content/60 text-sm">Carregando itens...</p>
|
||||||
|
{:else if items.length === 0}
|
||||||
|
<p class="text-base-content/60 text-sm">Nenhum item adicionado.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each grouped as group (group.key)}
|
||||||
|
{@const dfd = group.isSemDfd ? null : group.key}
|
||||||
|
{@const pedidoLink = dfd ? pedidosByDfd[dfd] : null}
|
||||||
|
<GlassCard bodyClass="p-0">
|
||||||
|
<div
|
||||||
|
class="border-base-300 bg-base-200/50 flex items-center justify-between border-b px-6 py-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="font-semibold">{group.label}</div>
|
||||||
|
{#if pedidoLink?.pedido}
|
||||||
|
<a
|
||||||
|
href={resolve(`/pedidos/${pedidoLink.pedido._id}`)}
|
||||||
|
class="text-primary text-xs hover:underline"
|
||||||
|
>
|
||||||
|
Pedido: {pedidoLink.pedido.numeroSei || pedidoLink.pedido._id} — {formatPedidoStatus(
|
||||||
|
pedidoLink.pedido.status
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="text-base-content/60 text-xs">{group.items.length} item(ns)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table-zebra table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
|
>Objeto</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
|
>Qtd</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
|
>Valor Est.</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
||||||
|
>DFD</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
|
||||||
|
>Ações</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each group.items as it (it._id)}
|
||||||
|
<tr class="hover:bg-base-200/50 transition-colors">
|
||||||
|
<td class="whitespace-nowrap">
|
||||||
|
{it.objetoNome}
|
||||||
|
{#if it.objetoUnidade}
|
||||||
|
<span class="text-base-content/60 ml-2 text-xs">
|
||||||
|
({it.objetoUnidade})
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap">
|
||||||
|
{#if isRascunho}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="input input-bordered input-sm w-24"
|
||||||
|
value={it.quantidade}
|
||||||
|
onchange={(e) =>
|
||||||
|
updateItemField(it._id, {
|
||||||
|
quantidade: parseInt(e.currentTarget.value, 10) || 1
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
{it.quantidade}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap">
|
||||||
|
{#if isRascunho}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-40"
|
||||||
|
value={it.valorEstimado}
|
||||||
|
onblur={(e) =>
|
||||||
|
updateItemField(it._id, { valorEstimado: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
{it.valorEstimado}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap">
|
||||||
|
{#if isRascunho}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-32"
|
||||||
|
value={it.numeroDfd || ''}
|
||||||
|
placeholder="(opcional)"
|
||||||
|
onblur={(e) => {
|
||||||
|
const v = e.currentTarget.value.trim();
|
||||||
|
updateItemField(it._id, { numeroDfd: v ? v : null });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
{it.numeroDfd || '-'}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="text-right whitespace-nowrap">
|
||||||
|
{#if isRascunho}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm text-error"
|
||||||
|
onclick={() => removeItem(it._id)}
|
||||||
|
title="Remover item"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="text-base-content/50 text-xs">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<!-- Pedidos -->
|
||||||
|
<GlassCard bodyClass="p-0">
|
||||||
|
<div class="border-base-300 border-b px-6 py-4">
|
||||||
|
<h2 class="text-lg font-semibold">Pedidos gerados</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
{#if pedidosQuery.isLoading}
|
||||||
|
<p class="text-base-content/60 text-sm">Carregando pedidos...</p>
|
||||||
|
{:else if pedidosLinks.length === 0}
|
||||||
|
<p class="text-base-content/60 text-sm">Nenhum pedido gerado ainda.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each pedidosLinks as row (row._id)}
|
||||||
|
<div class="border-base-300 rounded-lg border p-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-semibold">DFD: {row.numeroDfd}</div>
|
||||||
|
{#if row.pedido}
|
||||||
|
<a
|
||||||
|
class="text-primary text-sm hover:underline"
|
||||||
|
href={resolve(`/pedidos/${row.pedido._id}`)}
|
||||||
|
>
|
||||||
|
Pedido: {row.pedido.numeroSei || row.pedido._id}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if row.pedido}
|
||||||
|
<span class="badge badge-sm {getPedidoBadgeClass(row.pedido.status)}">
|
||||||
|
{formatPedidoStatus(row.pedido.status)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if row.lastHistory && row.lastHistory.length > 0}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="text-base-content/60 mb-2 text-xs font-semibold">Últimas ações</div>
|
||||||
|
<ul class="text-base-content/70 space-y-1 text-xs">
|
||||||
|
{#each row.lastHistory as h (h._id)}
|
||||||
|
<li class="flex items-center justify-between gap-2">
|
||||||
|
<span class="truncate">{h.usuarioNome}: {h.acao}</span>
|
||||||
|
<span class="text-base-content/50 shrink-0"
|
||||||
|
>{new Date(h.data).toLocaleString('pt-BR')}</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<!-- Add item modal -->
|
||||||
|
{#if showAddItemModal && addItemConfig.objeto}
|
||||||
|
<div class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-circle absolute top-2 right-2"
|
||||||
|
onclick={closeAddItemModal}
|
||||||
|
aria-label="Fechar modal"
|
||||||
|
disabled={addingItem}
|
||||||
|
>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-bold">Adicionar item</h3>
|
||||||
|
|
||||||
|
<div class="bg-base-200/50 mt-4 rounded-lg p-4">
|
||||||
|
<p class="font-semibold">{addItemConfig.objeto.nome}</p>
|
||||||
|
<p class="text-base-content/70 text-sm">Unidade: {addItemConfig.objeto.unidade}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="ai_qtd">
|
||||||
|
<span class="label-text font-semibold">Quantidade</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ai_qtd"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
bind:value={addItemConfig.quantidade}
|
||||||
|
disabled={addingItem}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="ai_valor">
|
||||||
|
<span class="label-text font-semibold">Valor estimado</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ai_valor"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
bind:value={addItemConfig.valorEstimado}
|
||||||
|
disabled={addingItem}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="ai_dfd">
|
||||||
|
<span class="label-text font-semibold">Número DFD (opcional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ai_dfd"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
bind:value={addItemConfig.numeroDfd}
|
||||||
|
disabled={addingItem}
|
||||||
|
placeholder="Ex: 123/2025"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn" onclick={closeAddItemModal} disabled={addingItem}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={confirmAddItem}
|
||||||
|
disabled={addingItem}
|
||||||
|
>
|
||||||
|
{#if addingItem}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{/if}
|
||||||
|
{addingItem ? 'Adicionando...' : 'Adicionar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="modal-backdrop"
|
||||||
|
onclick={closeAddItemModal}
|
||||||
|
aria-label="Fechar modal"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Gerar pedidos modal -->
|
||||||
|
{#if showGerarModal}
|
||||||
|
<div class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-circle absolute top-2 right-2"
|
||||||
|
onclick={closeGerarModal}
|
||||||
|
aria-label="Fechar modal"
|
||||||
|
disabled={gerando}
|
||||||
|
>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-bold">Gerar pedidos</h3>
|
||||||
|
<p class="text-base-content/70 mt-2 text-sm">
|
||||||
|
Será criado <strong>1 pedido por DFD</strong>. Informe o número SEI de cada pedido.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-3">
|
||||||
|
{#each dfdsParaGerar as dfd (dfd)}
|
||||||
|
<div class="border-base-300 grid gap-3 rounded-lg border p-4 md:grid-cols-3">
|
||||||
|
<div class="md:col-span-1">
|
||||||
|
<div class="text-base-content/60 text-xs font-semibold">DFD</div>
|
||||||
|
<div class="text-sm font-semibold">{dfd}</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="label py-0" for={`sei_${dfd}`}>
|
||||||
|
<span class="label-text font-semibold">Número SEI</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`sei_${dfd}`}
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
bind:value={seiByDfd[dfd]}
|
||||||
|
disabled={gerando}
|
||||||
|
placeholder="Ex: 12345.000000/2025-00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn" onclick={closeGerarModal} disabled={gerando}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={confirmarGeracao}
|
||||||
|
disabled={gerando}
|
||||||
|
>
|
||||||
|
{#if gerando}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{/if}
|
||||||
|
{gerando ? 'Gerando...' : 'Confirmar e gerar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="modal-backdrop"
|
||||||
|
onclick={closeGerarModal}
|
||||||
|
aria-label="Fechar modal"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</PageShell>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
Users,
|
Users,
|
||||||
Inbox,
|
Inbox,
|
||||||
Search,
|
Search,
|
||||||
AlertTriangle,
|
TriangleAlert,
|
||||||
User
|
User
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import type { FunctionReturnType } from 'convex/server';
|
import type { FunctionReturnType } from 'convex/server';
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
const list = $derived(funcionariosQuery.data ?? []);
|
const list = $derived(funcionariosQuery.data ?? []);
|
||||||
|
|
||||||
let funcionarioToDelete = $derived<Funcionario | null>(null);
|
let funcionarioToDelete = $derived<Funcionario | null>(null);
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
let filtro = $state('');
|
let filtro = $state('');
|
||||||
let notice: { kind: 'success' | 'error'; text: string } | null = $state(null);
|
let notice: { kind: 'success' | 'error'; text: string } | null = $state(null);
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
if (!funcionarioToDelete) return;
|
if (!funcionarioToDelete) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
deleting = true;
|
||||||
await client.mutation(api.funcionarios.remove, { id: funcionarioToDelete._id });
|
await client.mutation(api.funcionarios.remove, { id: funcionarioToDelete._id });
|
||||||
closeDeleteModal();
|
closeDeleteModal();
|
||||||
notice = {
|
notice = {
|
||||||
@@ -56,6 +58,8 @@
|
|||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
notice = { kind: 'error', text: 'Erro ao excluir cadastro. Tente novamente.' };
|
notice = { kind: 'error', text: 'Erro ao excluir cadastro. Tente novamente.' };
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,12 +247,12 @@
|
|||||||
<dialog id="delete_modal_func_excluir" class="modal">
|
<dialog id="delete_modal_func_excluir" class="modal">
|
||||||
<div class="modal-box max-w-md">
|
<div class="modal-box max-w-md">
|
||||||
<h3 class="text-error mb-4 flex items-center gap-2 text-2xl font-bold">
|
<h3 class="text-error mb-4 flex items-center gap-2 text-2xl font-bold">
|
||||||
<AlertTriangle class="h-7 w-7" strokeWidth={2} />
|
<TriangleAlert class="h-7 w-7" strokeWidth={2} />
|
||||||
Confirmar Exclusão
|
Confirmar Exclusão
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="alert alert-warning mb-4">
|
<div class="alert alert-warning mb-4">
|
||||||
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
<TriangleAlert class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
||||||
<div>
|
<div>
|
||||||
<span class="font-bold">Atenção!</span>
|
<span class="font-bold">Atenção!</span>
|
||||||
<p class="text-sm">Esta ação não pode ser desfeita!</p>
|
<p class="text-sm">Esta ação não pode ser desfeita!</p>
|
||||||
@@ -280,16 +284,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="modal-action justify-between">
|
<div class="modal-action justify-between">
|
||||||
<button class="btn gap-2" onclick={closeDeleteModal} disabled={funcionarioToDelete !== null}>
|
<button class="btn gap-2" onclick={closeDeleteModal} disabled={deleting}>
|
||||||
<X class="h-5 w-5" strokeWidth={2} />
|
<X class="h-5 w-5" strokeWidth={2} />
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-error gap-2"
|
class="btn btn-error gap-2"
|
||||||
onclick={confirmDelete}
|
onclick={confirmDelete}
|
||||||
disabled={funcionarioToDelete !== null}
|
disabled={deleting || funcionarioToDelete === null}
|
||||||
>
|
>
|
||||||
{#if funcionarioToDelete}
|
{#if deleting}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
Excluindo...
|
Excluindo...
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit';
|
import { createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit';
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { FunctionReference } from 'convex/server';
|
|
||||||
|
|
||||||
export const load = async ({ locals, url }) => {
|
export const load = async ({ locals, url }) => {
|
||||||
try {
|
if (!locals.token) {
|
||||||
|
return { currentUser: null };
|
||||||
|
}
|
||||||
|
|
||||||
const client = createConvexHttpClient({ token: locals.token });
|
const client = createConvexHttpClient({ token: locals.token });
|
||||||
const currentUser = await client.query(api.auth.getCurrentUser as FunctionReference<'query'>);
|
const currentUser = await client.query(api.auth.getCurrentUser);
|
||||||
|
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
const redirectTo = url.searchParams.get('redirect');
|
const redirectTo = url.searchParams.get('redirect');
|
||||||
@@ -15,10 +17,6 @@ export const load = async ({ locals, url }) => {
|
|||||||
}
|
}
|
||||||
throw redirect(302, '/');
|
throw redirect(302, '/');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
// Se houver falha transitória na API/auth, ainda assim permitir renderizar a página de login.
|
|
||||||
console.error('Erro ao validar sessão na página de login:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
return { currentUser };
|
||||||
};
|
};
|
||||||
|
|||||||
4
packages/backend/convex/_generated/api.d.ts
vendored
4
packages/backend/convex/_generated/api.d.ts
vendored
@@ -52,6 +52,7 @@ import type * as monitoramento from "../monitoramento.js";
|
|||||||
import type * as objetos from "../objetos.js";
|
import type * as objetos from "../objetos.js";
|
||||||
import type * as pedidos from "../pedidos.js";
|
import type * as pedidos from "../pedidos.js";
|
||||||
import type * as permissoesAcoes from "../permissoesAcoes.js";
|
import type * as permissoesAcoes from "../permissoesAcoes.js";
|
||||||
|
import type * as planejamentos from "../planejamentos.js";
|
||||||
import type * as pontos from "../pontos.js";
|
import type * as pontos from "../pontos.js";
|
||||||
import type * as preferenciasNotificacao from "../preferenciasNotificacao.js";
|
import type * as preferenciasNotificacao from "../preferenciasNotificacao.js";
|
||||||
import type * as pushNotifications from "../pushNotifications.js";
|
import type * as pushNotifications from "../pushNotifications.js";
|
||||||
@@ -77,6 +78,7 @@ import type * as tables_lgpdTables from "../tables/lgpdTables.js";
|
|||||||
import type * as tables_licencas from "../tables/licencas.js";
|
import type * as tables_licencas from "../tables/licencas.js";
|
||||||
import type * as tables_objetos from "../tables/objetos.js";
|
import type * as tables_objetos from "../tables/objetos.js";
|
||||||
import type * as tables_pedidos from "../tables/pedidos.js";
|
import type * as tables_pedidos from "../tables/pedidos.js";
|
||||||
|
import type * as tables_planejamentos from "../tables/planejamentos.js";
|
||||||
import type * as tables_ponto from "../tables/ponto.js";
|
import type * as tables_ponto from "../tables/ponto.js";
|
||||||
import type * as tables_security from "../tables/security.js";
|
import type * as tables_security from "../tables/security.js";
|
||||||
import type * as tables_setores from "../tables/setores.js";
|
import type * as tables_setores from "../tables/setores.js";
|
||||||
@@ -144,6 +146,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
objetos: typeof objetos;
|
objetos: typeof objetos;
|
||||||
pedidos: typeof pedidos;
|
pedidos: typeof pedidos;
|
||||||
permissoesAcoes: typeof permissoesAcoes;
|
permissoesAcoes: typeof permissoesAcoes;
|
||||||
|
planejamentos: typeof planejamentos;
|
||||||
pontos: typeof pontos;
|
pontos: typeof pontos;
|
||||||
preferenciasNotificacao: typeof preferenciasNotificacao;
|
preferenciasNotificacao: typeof preferenciasNotificacao;
|
||||||
pushNotifications: typeof pushNotifications;
|
pushNotifications: typeof pushNotifications;
|
||||||
@@ -169,6 +172,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
"tables/licencas": typeof tables_licencas;
|
"tables/licencas": typeof tables_licencas;
|
||||||
"tables/objetos": typeof tables_objetos;
|
"tables/objetos": typeof tables_objetos;
|
||||||
"tables/pedidos": typeof tables_pedidos;
|
"tables/pedidos": typeof tables_pedidos;
|
||||||
|
"tables/planejamentos": typeof tables_planejamentos;
|
||||||
"tables/ponto": typeof tables_ponto;
|
"tables/ponto": typeof tables_ponto;
|
||||||
"tables/security": typeof tables_security;
|
"tables/security": typeof tables_security;
|
||||||
"tables/setores": typeof tables_setores;
|
"tables/setores": typeof tables_setores;
|
||||||
|
|||||||
@@ -4,14 +4,67 @@ import type { Id } from './_generated/dataModel';
|
|||||||
import { getCurrentUserFunction } from './auth';
|
import { getCurrentUserFunction } from './auth';
|
||||||
import { internal } from './_generated/api';
|
import { internal } from './_generated/api';
|
||||||
|
|
||||||
|
function normalizeLimitePercentual(value: number | undefined): number {
|
||||||
|
const fallback = 50;
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
if (!Number.isFinite(value)) return fallback;
|
||||||
|
if (value < 0) return 0;
|
||||||
|
if (value > 100) return 100;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertQuantidadeTotalValida(value: number) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
throw new Error('Quantidade do produto na ata deve ser maior que zero.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: {},
|
args: {
|
||||||
handler: async (ctx) => {
|
periodoInicio: v.optional(v.string()),
|
||||||
|
periodoFim: v.optional(v.string()),
|
||||||
|
numero: v.optional(v.string()),
|
||||||
|
numeroSei: v.optional(v.string())
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||||
recurso: 'atas',
|
recurso: 'atas',
|
||||||
acao: 'listar'
|
acao: 'listar'
|
||||||
});
|
});
|
||||||
return await ctx.db.query('atas').collect();
|
|
||||||
|
const numero = args.numero?.trim().toLowerCase();
|
||||||
|
const numeroSei = args.numeroSei?.trim().toLowerCase();
|
||||||
|
const periodoInicio = args.periodoInicio || undefined;
|
||||||
|
const periodoFim = args.periodoFim || undefined;
|
||||||
|
|
||||||
|
const atas = await ctx.db.query('atas').collect();
|
||||||
|
return atas.filter((ata) => {
|
||||||
|
const numeroOk = !numero || (ata.numero || '').toLowerCase().includes(numero);
|
||||||
|
const seiOk = !numeroSei || (ata.numeroSei || '').toLowerCase().includes(numeroSei);
|
||||||
|
|
||||||
|
// Filtro por intervalo (range): retorna atas cuja vigência intersecta o período informado.
|
||||||
|
// Considera datas como strings "YYYY-MM-DD" (lexicograficamente comparáveis).
|
||||||
|
const ataInicio = ata.dataInicio ?? '0000-01-01';
|
||||||
|
const ataFimEfetivo = (() => {
|
||||||
|
const a = ata.dataFim;
|
||||||
|
const b = (ata as { dataProrrogacao?: string }).dataProrrogacao;
|
||||||
|
if (!a && !b) return '9999-12-31';
|
||||||
|
if (!a) return b!;
|
||||||
|
if (!b) return a;
|
||||||
|
return a >= b ? a : b;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const periodoOk =
|
||||||
|
(!periodoInicio && !periodoFim) ||
|
||||||
|
(periodoInicio &&
|
||||||
|
periodoFim &&
|
||||||
|
ataInicio <= periodoFim &&
|
||||||
|
ataFimEfetivo >= periodoInicio) ||
|
||||||
|
(periodoInicio && !periodoFim && ataFimEfetivo >= periodoInicio) ||
|
||||||
|
(!periodoInicio && periodoFim && ataInicio <= periodoFim);
|
||||||
|
|
||||||
|
return numeroOk && seiOk && periodoOk;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,6 +96,34 @@ export const getObjetos = query({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getObjetosConfig = query({
|
||||||
|
args: { id: v.id('atas') },
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
objetoId: v.id('objetos'),
|
||||||
|
quantidadeTotal: v.union(v.number(), v.null()),
|
||||||
|
limitePercentual: v.union(v.number(), v.null())
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||||
|
recurso: 'atas',
|
||||||
|
acao: 'ver'
|
||||||
|
});
|
||||||
|
|
||||||
|
const links = await ctx.db
|
||||||
|
.query('atasObjetos')
|
||||||
|
.withIndex('by_ataId', (q) => q.eq('ataId', args.id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return links.map((l) => ({
|
||||||
|
objetoId: l.objetoId,
|
||||||
|
quantidadeTotal: l.quantidadeTotal ?? null,
|
||||||
|
limitePercentual: l.limitePercentual ?? null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const listByObjetoIds = query({
|
export const listByObjetoIds = query({
|
||||||
args: {
|
args: {
|
||||||
objetoIds: v.array(v.id('objetos'))
|
objetoIds: v.array(v.id('objetos'))
|
||||||
@@ -78,9 +159,16 @@ export const create = mutation({
|
|||||||
numero: v.string(),
|
numero: v.string(),
|
||||||
dataInicio: v.optional(v.string()),
|
dataInicio: v.optional(v.string()),
|
||||||
dataFim: v.optional(v.string()),
|
dataFim: v.optional(v.string()),
|
||||||
|
dataProrrogacao: v.optional(v.string()),
|
||||||
empresaId: v.id('empresas'),
|
empresaId: v.id('empresas'),
|
||||||
numeroSei: v.string(),
|
numeroSei: v.string(),
|
||||||
objetosIds: v.array(v.id('objetos'))
|
objetos: v.array(
|
||||||
|
v.object({
|
||||||
|
objetoId: v.id('objetos'),
|
||||||
|
quantidadeTotal: v.number(),
|
||||||
|
limitePercentual: v.optional(v.number())
|
||||||
|
})
|
||||||
|
)
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||||
@@ -97,16 +185,23 @@ export const create = mutation({
|
|||||||
empresaId: args.empresaId,
|
empresaId: args.empresaId,
|
||||||
dataInicio: args.dataInicio,
|
dataInicio: args.dataInicio,
|
||||||
dataFim: args.dataFim,
|
dataFim: args.dataFim,
|
||||||
|
dataProrrogacao: args.dataProrrogacao,
|
||||||
criadoPor: user._id,
|
criadoPor: user._id,
|
||||||
criadoEm: Date.now(),
|
criadoEm: Date.now(),
|
||||||
atualizadoEm: Date.now()
|
atualizadoEm: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Vincular objetos
|
// Vincular objetos
|
||||||
for (const objetoId of args.objetosIds) {
|
for (const cfg of args.objetos) {
|
||||||
|
assertQuantidadeTotalValida(cfg.quantidadeTotal);
|
||||||
|
const limitePercentual = normalizeLimitePercentual(cfg.limitePercentual);
|
||||||
|
|
||||||
await ctx.db.insert('atasObjetos', {
|
await ctx.db.insert('atasObjetos', {
|
||||||
ataId,
|
ataId,
|
||||||
objetoId
|
objetoId: cfg.objetoId,
|
||||||
|
quantidadeTotal: cfg.quantidadeTotal,
|
||||||
|
limitePercentual,
|
||||||
|
quantidadeUsada: 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +217,14 @@ export const update = mutation({
|
|||||||
empresaId: v.id('empresas'),
|
empresaId: v.id('empresas'),
|
||||||
dataInicio: v.optional(v.string()),
|
dataInicio: v.optional(v.string()),
|
||||||
dataFim: v.optional(v.string()),
|
dataFim: v.optional(v.string()),
|
||||||
objetosIds: v.array(v.id('objetos'))
|
dataProrrogacao: v.optional(v.string()),
|
||||||
|
objetos: v.array(
|
||||||
|
v.object({
|
||||||
|
objetoId: v.id('objetos'),
|
||||||
|
quantidadeTotal: v.number(),
|
||||||
|
limitePercentual: v.optional(v.number())
|
||||||
|
})
|
||||||
|
)
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||||
@@ -139,28 +241,78 @@ export const update = mutation({
|
|||||||
empresaId: args.empresaId,
|
empresaId: args.empresaId,
|
||||||
dataInicio: args.dataInicio,
|
dataInicio: args.dataInicio,
|
||||||
dataFim: args.dataFim,
|
dataFim: args.dataFim,
|
||||||
|
dataProrrogacao: args.dataProrrogacao,
|
||||||
atualizadoEm: Date.now()
|
atualizadoEm: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Atualizar objetos vinculados
|
// Atualizar objetos vinculados
|
||||||
// Primeiro remove todos os vínculos existentes
|
|
||||||
const existingLinks = await ctx.db
|
const existingLinks = await ctx.db
|
||||||
.query('atasObjetos')
|
.query('atasObjetos')
|
||||||
.withIndex('by_ataId', (q) => q.eq('ataId', args.id))
|
.withIndex('by_ataId', (q) => q.eq('ataId', args.id))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
const existingByObjeto = new Map<Id<'objetos'>, (typeof existingLinks)[number]>();
|
||||||
for (const link of existingLinks) {
|
for (const link of existingLinks) {
|
||||||
await ctx.db.delete(link._id);
|
existingByObjeto.set(link.objetoId, link);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adiciona os novos vínculos
|
const desiredObjetoIds = new Set<Id<'objetos'>>(args.objetos.map((o) => o.objetoId));
|
||||||
for (const objetoId of args.objetosIds) {
|
|
||||||
|
// Upsert dos vínculos desejados (preserva quantidadeUsada quando já existe)
|
||||||
|
for (const cfg of args.objetos) {
|
||||||
|
assertQuantidadeTotalValida(cfg.quantidadeTotal);
|
||||||
|
const limitePercentual = normalizeLimitePercentual(cfg.limitePercentual);
|
||||||
|
|
||||||
|
const existing = existingByObjeto.get(cfg.objetoId);
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
quantidadeTotal: cfg.quantidadeTotal,
|
||||||
|
limitePercentual
|
||||||
|
});
|
||||||
|
} else {
|
||||||
await ctx.db.insert('atasObjetos', {
|
await ctx.db.insert('atasObjetos', {
|
||||||
ataId: args.id,
|
ataId: args.id,
|
||||||
objetoId
|
objetoId: cfg.objetoId,
|
||||||
|
quantidadeTotal: cfg.quantidadeTotal,
|
||||||
|
limitePercentual,
|
||||||
|
quantidadeUsada: 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remoção de vínculos não selecionados (somente se não houver uso em pedidos não-cancelados)
|
||||||
|
for (const link of existingLinks) {
|
||||||
|
if (desiredObjetoIds.has(link.objetoId)) continue;
|
||||||
|
|
||||||
|
const items = await ctx.db
|
||||||
|
.query('objetoItems')
|
||||||
|
.withIndex('by_ataId_and_objetoId', (q) =>
|
||||||
|
q.eq('ataId', args.id).eq('objetoId', link.objetoId)
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Se existe qualquer item em pedido não cancelado, bloquear remoção do vínculo
|
||||||
|
let inUse = false;
|
||||||
|
const seenPedidos = new Set<Id<'pedidos'>>();
|
||||||
|
for (const item of items) {
|
||||||
|
if (seenPedidos.has(item.pedidoId)) continue;
|
||||||
|
seenPedidos.add(item.pedidoId);
|
||||||
|
const pedido = await ctx.db.get(item.pedidoId);
|
||||||
|
if (pedido && pedido.status !== 'cancelado') {
|
||||||
|
inUse = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inUse) {
|
||||||
|
throw new Error(
|
||||||
|
'Não é possível remover este objeto da ata porque já existe uso em pedidos não cancelados.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.delete(link._id);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const remove = mutation({
|
export const remove = mutation({
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { type MutationCtx, type QueryCtx, query } from './_generated/server';
|
|||||||
// Usar SITE_URL se disponível, caso contrário usar CONVEX_SITE_URL ou um valor padrão
|
// Usar SITE_URL se disponível, caso contrário usar CONVEX_SITE_URL ou um valor padrão
|
||||||
const siteUrl = process.env.SITE_URL || process.env.CONVEX_SITE_URL || 'http://localhost:5173';
|
const siteUrl = process.env.SITE_URL || process.env.CONVEX_SITE_URL || 'http://localhost:5173';
|
||||||
|
|
||||||
console.log('siteUrl:', siteUrl);
|
|
||||||
|
|
||||||
// The component client has methods needed for integrating Convex with Better Auth,
|
// The component client has methods needed for integrating Convex with Better Auth,
|
||||||
// as well as helper methods for general use.
|
// as well as helper methods for general use.
|
||||||
export const authComponent = createClient<DataModel>(components.betterAuth);
|
export const authComponent = createClient<DataModel>(components.betterAuth);
|
||||||
|
|||||||
@@ -5,15 +5,37 @@ import { getCurrentUserFunction } from './auth';
|
|||||||
import type { Id } from './_generated/dataModel';
|
import type { Id } from './_generated/dataModel';
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: {},
|
args: {
|
||||||
handler: async (ctx) => {
|
query: v.optional(v.string())
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||||
recurso: 'empresas',
|
recurso: 'empresas',
|
||||||
acao: 'listar'
|
acao: 'listar'
|
||||||
});
|
});
|
||||||
|
|
||||||
const empresas = await ctx.db.query('empresas').collect();
|
const empresas = await ctx.db.query('empresas').collect();
|
||||||
return empresas;
|
const term = args.query?.trim();
|
||||||
|
if (!term) return empresas;
|
||||||
|
|
||||||
|
const termLower = term.toLowerCase();
|
||||||
|
const termDigits = term.replace(/\D/g, '');
|
||||||
|
|
||||||
|
return empresas.filter((empresa) => {
|
||||||
|
const razao = (empresa.razao_social || '').toLowerCase();
|
||||||
|
const fantasia = (empresa.nome_fantasia || '').toLowerCase();
|
||||||
|
|
||||||
|
const cnpjRaw = empresa.cnpj || '';
|
||||||
|
const cnpjLower = cnpjRaw.toLowerCase();
|
||||||
|
const cnpjDigits = cnpjRaw.replace(/\D/g, '');
|
||||||
|
|
||||||
|
const matchNome = razao.includes(termLower) || fantasia.includes(termLower);
|
||||||
|
const matchCnpj = termDigits
|
||||||
|
? cnpjDigits.includes(termDigits)
|
||||||
|
: cnpjLower.includes(termLower);
|
||||||
|
|
||||||
|
return matchNome || matchCnpj;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,184 @@
|
|||||||
import { v } from 'convex/values';
|
import { v } from 'convex/values';
|
||||||
import { mutation, query } from './_generated/server';
|
import { mutation, query } from './_generated/server';
|
||||||
|
import type { Id } from './_generated/dataModel';
|
||||||
|
import type { QueryCtx } from './_generated/server';
|
||||||
import { getCurrentUserFunction } from './auth';
|
import { getCurrentUserFunction } from './auth';
|
||||||
|
import { addMonthsClampedYMD, getTodayYMD, isWithinRangeYMD, maxYMD } from './utils/datas';
|
||||||
|
|
||||||
|
const ataComLimiteValidator = v.object({
|
||||||
|
_id: v.id('atas'),
|
||||||
|
numero: v.string(),
|
||||||
|
numeroSei: v.string(),
|
||||||
|
dataInicio: v.union(v.string(), v.null()),
|
||||||
|
dataFim: v.union(v.string(), v.null()),
|
||||||
|
dataProrrogacao: v.union(v.string(), v.null()),
|
||||||
|
dataFimEfetiva: v.union(v.string(), v.null()),
|
||||||
|
quantidadeTotal: v.union(v.number(), v.null()),
|
||||||
|
limitePercentual: v.number(),
|
||||||
|
quantidadeUsada: v.number(),
|
||||||
|
limitePermitido: v.number(),
|
||||||
|
restante: v.number(),
|
||||||
|
isLocked: v.boolean(),
|
||||||
|
isVencidaRecentemente: v.boolean(),
|
||||||
|
lockReason: v.union(
|
||||||
|
v.literal('vigencia_expirada'),
|
||||||
|
v.literal('limite_atingido'),
|
||||||
|
v.literal('nao_configurada'),
|
||||||
|
v.null()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
async function computeAtasComLimiteForObjeto(
|
||||||
|
ctx: QueryCtx,
|
||||||
|
objetoId: Id<'objetos'>,
|
||||||
|
includeAtaIds?: Id<'atas'>[]
|
||||||
|
) {
|
||||||
|
const today = getTodayYMD();
|
||||||
|
const threeMonthsAgo = addMonthsClampedYMD(today, -3);
|
||||||
|
const includeSet = new Set<Id<'atas'>>(includeAtaIds ?? []);
|
||||||
|
|
||||||
|
const links = await ctx.db
|
||||||
|
.query('atasObjetos')
|
||||||
|
.withIndex('by_objetoId', (q) => q.eq('objetoId', objetoId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const results: Array<{
|
||||||
|
_id: Id<'atas'>;
|
||||||
|
numero: string;
|
||||||
|
numeroSei: string;
|
||||||
|
dataInicio: string | null;
|
||||||
|
dataFim: string | null;
|
||||||
|
dataProrrogacao: string | null;
|
||||||
|
dataFimEfetiva: string | null;
|
||||||
|
quantidadeTotal: number | null;
|
||||||
|
limitePercentual: number;
|
||||||
|
quantidadeUsada: number;
|
||||||
|
limitePermitido: number;
|
||||||
|
restante: number;
|
||||||
|
isLocked: boolean;
|
||||||
|
isVencidaRecentemente: boolean;
|
||||||
|
lockReason: 'vigencia_expirada' | 'limite_atingido' | 'nao_configurada' | null;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
const ata = await ctx.db.get(link.ataId);
|
||||||
|
if (!ata) continue;
|
||||||
|
|
||||||
|
const dataProrrogacao = (ata as { dataProrrogacao?: string }).dataProrrogacao ?? null;
|
||||||
|
const dataFimEfetiva = maxYMD(ata.dataFim ?? null, dataProrrogacao);
|
||||||
|
|
||||||
|
const vigenteHoje = isWithinRangeYMD(today, ata.dataInicio ?? null, dataFimEfetiva);
|
||||||
|
const isVencidaRecentemente =
|
||||||
|
!!dataFimEfetiva && dataFimEfetiva < today && dataFimEfetiva >= threeMonthsAgo;
|
||||||
|
|
||||||
|
// Para seleção em pedidos: manter somente vigentes e vencidas recentemente,
|
||||||
|
// mas permitir incluir atas específicas (ex.: já selecionadas em pedido antigo).
|
||||||
|
const shouldForceInclude = includeSet.has(ata._id);
|
||||||
|
if (!shouldForceInclude && !vigenteHoje && !isVencidaRecentemente) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const isForaDaVigencia = !vigenteHoje;
|
||||||
|
|
||||||
|
let quantidadeUsada = link.quantidadeUsada ?? 0;
|
||||||
|
if (link.quantidadeUsada === undefined) {
|
||||||
|
const items = await ctx.db
|
||||||
|
.query('objetoItems')
|
||||||
|
.withIndex('by_ataId_and_objetoId', (q) =>
|
||||||
|
q.eq('ataId', link.ataId).eq('objetoId', link.objetoId)
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const sumByPedidoId = new Map<Id<'pedidos'>, number>();
|
||||||
|
for (const item of items) {
|
||||||
|
const prev = sumByPedidoId.get(item.pedidoId) ?? 0;
|
||||||
|
sumByPedidoId.set(item.pedidoId, prev + item.quantidade);
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
for (const [pedidoId, sum] of sumByPedidoId.entries()) {
|
||||||
|
const pedido = await ctx.db.get(pedidoId);
|
||||||
|
if (pedido && pedido.status !== 'cancelado') {
|
||||||
|
total += sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quantidadeUsada = total;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantidadeTotal = link.quantidadeTotal ?? null;
|
||||||
|
const limitePercentualRaw = link.limitePercentual ?? 50;
|
||||||
|
const limitePercentual = Number.isFinite(limitePercentualRaw)
|
||||||
|
? Math.min(100, Math.max(0, limitePercentualRaw))
|
||||||
|
: 50;
|
||||||
|
|
||||||
|
const limitePermitido =
|
||||||
|
quantidadeTotal && quantidadeTotal > 0
|
||||||
|
? Math.floor(quantidadeTotal * (limitePercentual / 100))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const restante = Math.max(0, limitePermitido - quantidadeUsada);
|
||||||
|
|
||||||
|
const lockReason: 'nao_configurada' | 'limite_atingido' | 'vigencia_expirada' | null =
|
||||||
|
!quantidadeTotal || quantidadeTotal <= 0
|
||||||
|
? 'nao_configurada'
|
||||||
|
: quantidadeUsada >= limitePermitido
|
||||||
|
? 'limite_atingido'
|
||||||
|
: isForaDaVigencia
|
||||||
|
? 'vigencia_expirada'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isLocked = lockReason !== null;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
_id: ata._id,
|
||||||
|
numero: ata.numero,
|
||||||
|
numeroSei: ata.numeroSei,
|
||||||
|
dataInicio: ata.dataInicio ?? null,
|
||||||
|
dataFim: ata.dataFim ?? null,
|
||||||
|
dataProrrogacao,
|
||||||
|
dataFimEfetiva,
|
||||||
|
quantidadeTotal,
|
||||||
|
limitePercentual,
|
||||||
|
quantidadeUsada,
|
||||||
|
limitePermitido,
|
||||||
|
restante,
|
||||||
|
isLocked,
|
||||||
|
isVencidaRecentemente,
|
||||||
|
lockReason
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: {},
|
args: {
|
||||||
handler: async (ctx) => {
|
nome: v.optional(v.string()),
|
||||||
return await ctx.db.query('objetos').collect();
|
tipo: v.optional(v.union(v.literal('material'), v.literal('servico'))),
|
||||||
|
codigos: v.optional(v.string())
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const nome = args.nome?.trim();
|
||||||
|
const codigos = args.codigos?.trim().toLowerCase();
|
||||||
|
|
||||||
|
const base =
|
||||||
|
nome && nome.length > 0
|
||||||
|
? await ctx.db
|
||||||
|
.query('objetos')
|
||||||
|
.withSearchIndex('search_nome', (q) => q.search('nome', nome))
|
||||||
|
.collect()
|
||||||
|
: await ctx.db.query('objetos').collect();
|
||||||
|
|
||||||
|
return base.filter((objeto) => {
|
||||||
|
const tipoOk = !args.tipo || objeto.tipo === args.tipo;
|
||||||
|
|
||||||
|
const codigosOk =
|
||||||
|
!codigos ||
|
||||||
|
(objeto.codigoEfisco || '').toLowerCase().includes(codigos) ||
|
||||||
|
(objeto.codigoCatmat || '').toLowerCase().includes(codigos) ||
|
||||||
|
(objeto.codigoCatserv || '').toLowerCase().includes(codigos);
|
||||||
|
|
||||||
|
return tipoOk && codigosOk;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,6 +293,43 @@ export const getAtas = query({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getAtasComLimite = query({
|
||||||
|
args: {
|
||||||
|
objetoId: v.id('objetos'),
|
||||||
|
// Permite incluir atas específicas (ex.: já selecionadas em um pedido antigo),
|
||||||
|
// mesmo que não estejam vigentes ou não tenham vencido nos últimos 3 meses.
|
||||||
|
includeAtaIds: v.optional(v.array(v.id('atas')))
|
||||||
|
},
|
||||||
|
returns: v.array(ataComLimiteValidator),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await computeAtasComLimiteForObjeto(ctx, args.objetoId, args.includeAtaIds);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getAtasComLimiteBatch = query({
|
||||||
|
args: {
|
||||||
|
objetoIds: v.array(v.id('objetos')),
|
||||||
|
includeAtaIds: v.optional(v.array(v.id('atas')))
|
||||||
|
},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
objetoId: v.id('objetos'),
|
||||||
|
atas: v.array(ataComLimiteValidator)
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
if (args.objetoIds.length === 0) return [];
|
||||||
|
const include = args.includeAtaIds ?? [];
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const objetoId of args.objetoIds) {
|
||||||
|
const atas = await computeAtasComLimiteForObjeto(ctx, objetoId, include);
|
||||||
|
out.push({ objetoId, atas });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const remove = mutation({
|
export const remove = mutation({
|
||||||
args: {
|
args: {
|
||||||
id: v.id('objetos')
|
id: v.id('objetos')
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
514
packages/backend/convex/planejamentos.ts
Normal file
514
packages/backend/convex/planejamentos.ts
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
import { v } from 'convex/values';
|
||||||
|
import type { Doc, Id } from './_generated/dataModel';
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
import { getCurrentUserFunction } from './auth';
|
||||||
|
|
||||||
|
async function getUsuarioAutenticado(ctx: Parameters<typeof getCurrentUserFunction>[0]) {
|
||||||
|
const user = await getCurrentUserFunction(ctx);
|
||||||
|
if (!user) throw new Error('Unauthorized');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalString(value: string | undefined): string | undefined {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== QUERIES ==========
|
||||||
|
|
||||||
|
export const list = query({
|
||||||
|
args: {
|
||||||
|
status: v.optional(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado'))),
|
||||||
|
responsavelId: v.optional(v.id('funcionarios'))
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const status = args.status;
|
||||||
|
const responsavelId = args.responsavelId;
|
||||||
|
|
||||||
|
let base: Doc<'planejamentosPedidos'>[] = [];
|
||||||
|
|
||||||
|
if (responsavelId) {
|
||||||
|
base = await ctx.db
|
||||||
|
.query('planejamentosPedidos')
|
||||||
|
.withIndex('by_responsavelId', (q) => q.eq('responsavelId', responsavelId))
|
||||||
|
.collect();
|
||||||
|
} else if (status) {
|
||||||
|
base = await ctx.db
|
||||||
|
.query('planejamentosPedidos')
|
||||||
|
.withIndex('by_status', (q) => q.eq('status', status))
|
||||||
|
.collect();
|
||||||
|
} else {
|
||||||
|
base = await ctx.db.query('planejamentosPedidos').collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
base.sort((a, b) => b.criadoEm - a.criadoEm);
|
||||||
|
|
||||||
|
return await Promise.all(
|
||||||
|
base.map(async (p) => {
|
||||||
|
const [responsavel, acao] = await Promise.all([
|
||||||
|
ctx.db.get(p.responsavelId),
|
||||||
|
p.acaoId ? ctx.db.get(p.acaoId) : Promise.resolve(null)
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
responsavelNome: responsavel?.nome ?? 'Desconhecido',
|
||||||
|
acaoNome: acao?.nome ?? undefined
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const get = query({
|
||||||
|
args: { id: v.id('planejamentosPedidos') },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const p = await ctx.db.get(args.id);
|
||||||
|
if (!p) return null;
|
||||||
|
|
||||||
|
const [responsavel, acao] = await Promise.all([
|
||||||
|
ctx.db.get(p.responsavelId),
|
||||||
|
p.acaoId ? ctx.db.get(p.acaoId) : Promise.resolve(null)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
responsavelNome: responsavel?.nome ?? 'Desconhecido',
|
||||||
|
acaoNome: acao?.nome ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listItems = query({
|
||||||
|
args: { planejamentoId: v.id('planejamentosPedidos') },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const items = await ctx.db
|
||||||
|
.query('planejamentoItens')
|
||||||
|
.withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', args.planejamentoId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Ordenação útil: primeiro sem pedido, depois por numeroDfd, depois por criadoEm
|
||||||
|
items.sort((a, b) => {
|
||||||
|
const ap = a.pedidoId ? 1 : 0;
|
||||||
|
const bp = b.pedidoId ? 1 : 0;
|
||||||
|
if (ap !== bp) return ap - bp;
|
||||||
|
const ad = (a.numeroDfd ?? '').localeCompare(b.numeroDfd ?? '');
|
||||||
|
if (ad !== 0) return ad;
|
||||||
|
return a.criadoEm - b.criadoEm;
|
||||||
|
});
|
||||||
|
|
||||||
|
return await Promise.all(
|
||||||
|
items.map(async (it) => {
|
||||||
|
const [objeto, pedido] = await Promise.all([
|
||||||
|
ctx.db.get(it.objetoId),
|
||||||
|
it.pedidoId ? ctx.db.get(it.pedidoId) : Promise.resolve(null)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...it,
|
||||||
|
objetoNome: objeto?.nome ?? 'Objeto desconhecido',
|
||||||
|
objetoUnidade: objeto?.unidade ?? '',
|
||||||
|
pedidoNumeroSei: pedido?.numeroSei ?? undefined,
|
||||||
|
pedidoStatus: pedido?.status ?? undefined
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listPedidos = query({
|
||||||
|
args: { planejamentoId: v.id('planejamentosPedidos') },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const links = await ctx.db
|
||||||
|
.query('planejamentoPedidosLinks')
|
||||||
|
.withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', args.planejamentoId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
links.sort((a, b) => a.numeroDfd.localeCompare(b.numeroDfd));
|
||||||
|
|
||||||
|
return await Promise.all(
|
||||||
|
links.map(async (link) => {
|
||||||
|
const pedido = await ctx.db.get(link.pedidoId);
|
||||||
|
if (!pedido) {
|
||||||
|
return {
|
||||||
|
...link,
|
||||||
|
pedido: null,
|
||||||
|
lastHistory: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await ctx.db
|
||||||
|
.query('historicoPedidos')
|
||||||
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', link.pedidoId))
|
||||||
|
.order('desc')
|
||||||
|
.take(3);
|
||||||
|
|
||||||
|
const historyWithNames = await Promise.all(
|
||||||
|
history.map(async (h) => {
|
||||||
|
const usuario = await ctx.db.get(h.usuarioId);
|
||||||
|
return {
|
||||||
|
...h,
|
||||||
|
usuarioNome: usuario?.nome ?? 'Desconhecido'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...link,
|
||||||
|
pedido: {
|
||||||
|
_id: pedido._id,
|
||||||
|
numeroSei: pedido.numeroSei,
|
||||||
|
numeroDfd: pedido.numeroDfd,
|
||||||
|
status: pedido.status,
|
||||||
|
criadoEm: pedido.criadoEm,
|
||||||
|
atualizadoEm: pedido.atualizadoEm
|
||||||
|
},
|
||||||
|
lastHistory: historyWithNames
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== MUTATIONS ==========
|
||||||
|
|
||||||
|
export const create = mutation({
|
||||||
|
args: {
|
||||||
|
titulo: v.string(),
|
||||||
|
descricao: v.string(),
|
||||||
|
data: v.string(),
|
||||||
|
responsavelId: v.id('funcionarios'),
|
||||||
|
acaoId: v.optional(v.id('acoes'))
|
||||||
|
},
|
||||||
|
returns: v.id('planejamentosPedidos'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getUsuarioAutenticado(ctx);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const titulo = args.titulo.trim();
|
||||||
|
const descricao = args.descricao.trim();
|
||||||
|
const data = args.data.trim();
|
||||||
|
|
||||||
|
if (!titulo) throw new Error('Informe um título.');
|
||||||
|
if (!descricao) throw new Error('Informe uma descrição.');
|
||||||
|
if (!data) throw new Error('Informe uma data.');
|
||||||
|
|
||||||
|
return await ctx.db.insert('planejamentosPedidos', {
|
||||||
|
titulo,
|
||||||
|
descricao,
|
||||||
|
data,
|
||||||
|
responsavelId: args.responsavelId,
|
||||||
|
acaoId: args.acaoId,
|
||||||
|
status: 'rascunho',
|
||||||
|
criadoPor: user._id,
|
||||||
|
criadoEm: now,
|
||||||
|
atualizadoEm: now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const update = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id('planejamentosPedidos'),
|
||||||
|
titulo: v.optional(v.string()),
|
||||||
|
descricao: v.optional(v.string()),
|
||||||
|
data: v.optional(v.string()),
|
||||||
|
responsavelId: v.optional(v.id('funcionarios')),
|
||||||
|
acaoId: v.optional(v.union(v.id('acoes'), v.null()))
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await getUsuarioAutenticado(ctx);
|
||||||
|
const p = await ctx.db.get(args.id);
|
||||||
|
if (!p) throw new Error('Planejamento não encontrado.');
|
||||||
|
if (p.status !== 'rascunho')
|
||||||
|
throw new Error('Apenas planejamentos em rascunho podem ser editados.');
|
||||||
|
|
||||||
|
const patch: Partial<Doc<'planejamentosPedidos'>> & { acaoId?: Id<'acoes'> | undefined } = {};
|
||||||
|
|
||||||
|
if (args.titulo !== undefined) {
|
||||||
|
const t = args.titulo.trim();
|
||||||
|
if (!t) throw new Error('Título não pode ficar vazio.');
|
||||||
|
patch.titulo = t;
|
||||||
|
}
|
||||||
|
if (args.descricao !== undefined) {
|
||||||
|
const d = args.descricao.trim();
|
||||||
|
if (!d) throw new Error('Descrição não pode ficar vazia.');
|
||||||
|
patch.descricao = d;
|
||||||
|
}
|
||||||
|
if (args.data !== undefined) {
|
||||||
|
const dt = args.data.trim();
|
||||||
|
if (!dt) throw new Error('Data não pode ficar vazia.');
|
||||||
|
patch.data = dt;
|
||||||
|
}
|
||||||
|
if (args.responsavelId !== undefined) {
|
||||||
|
patch.responsavelId = args.responsavelId;
|
||||||
|
}
|
||||||
|
if (args.acaoId !== undefined) {
|
||||||
|
patch.acaoId = args.acaoId === null ? undefined : args.acaoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
patch.atualizadoEm = Date.now();
|
||||||
|
await ctx.db.patch(args.id, patch);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addItem = mutation({
|
||||||
|
args: {
|
||||||
|
planejamentoId: v.id('planejamentosPedidos'),
|
||||||
|
objetoId: v.id('objetos'),
|
||||||
|
quantidade: v.number(),
|
||||||
|
valorEstimado: v.string(),
|
||||||
|
numeroDfd: v.optional(v.string())
|
||||||
|
},
|
||||||
|
returns: v.id('planejamentoItens'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await getUsuarioAutenticado(ctx);
|
||||||
|
const p = await ctx.db.get(args.planejamentoId);
|
||||||
|
if (!p) throw new Error('Planejamento não encontrado.');
|
||||||
|
if (p.status !== 'rascunho')
|
||||||
|
throw new Error('Apenas planejamentos em rascunho podem ser editados.');
|
||||||
|
|
||||||
|
if (!Number.isFinite(args.quantidade) || args.quantidade <= 0) {
|
||||||
|
throw new Error('Quantidade inválida.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const numeroDfd = normalizeOptionalString(args.numeroDfd);
|
||||||
|
const valorEstimado = args.valorEstimado.trim();
|
||||||
|
if (!valorEstimado) throw new Error('Valor estimado inválido.');
|
||||||
|
|
||||||
|
const itemId = await ctx.db.insert('planejamentoItens', {
|
||||||
|
planejamentoId: args.planejamentoId,
|
||||||
|
numeroDfd,
|
||||||
|
objetoId: args.objetoId,
|
||||||
|
quantidade: args.quantidade,
|
||||||
|
valorEstimado,
|
||||||
|
criadoEm: now,
|
||||||
|
atualizadoEm: now
|
||||||
|
});
|
||||||
|
await ctx.db.patch(args.planejamentoId, { atualizadoEm: Date.now() });
|
||||||
|
return itemId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateItem = mutation({
|
||||||
|
args: {
|
||||||
|
itemId: v.id('planejamentoItens'),
|
||||||
|
numeroDfd: v.optional(v.union(v.string(), v.null())),
|
||||||
|
quantidade: v.optional(v.number()),
|
||||||
|
valorEstimado: v.optional(v.string())
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await getUsuarioAutenticado(ctx);
|
||||||
|
const it = await ctx.db.get(args.itemId);
|
||||||
|
if (!it) throw new Error('Item não encontrado.');
|
||||||
|
|
||||||
|
const p = await ctx.db.get(it.planejamentoId);
|
||||||
|
if (!p) throw new Error('Planejamento não encontrado.');
|
||||||
|
if (p.status !== 'rascunho')
|
||||||
|
throw new Error('Apenas planejamentos em rascunho podem ser editados.');
|
||||||
|
|
||||||
|
const patch: Partial<Doc<'planejamentoItens'>> = { atualizadoEm: Date.now() };
|
||||||
|
|
||||||
|
if (args.numeroDfd !== undefined) {
|
||||||
|
patch.numeroDfd =
|
||||||
|
args.numeroDfd === null
|
||||||
|
? undefined
|
||||||
|
: (normalizeOptionalString(args.numeroDfd) ?? undefined);
|
||||||
|
}
|
||||||
|
if (args.quantidade !== undefined) {
|
||||||
|
if (!Number.isFinite(args.quantidade) || args.quantidade <= 0) {
|
||||||
|
throw new Error('Quantidade inválida.');
|
||||||
|
}
|
||||||
|
patch.quantidade = args.quantidade;
|
||||||
|
}
|
||||||
|
if (args.valorEstimado !== undefined) {
|
||||||
|
const vEst = args.valorEstimado.trim();
|
||||||
|
if (!vEst) throw new Error('Valor estimado inválido.');
|
||||||
|
patch.valorEstimado = vEst;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.itemId, patch);
|
||||||
|
await ctx.db.patch(it.planejamentoId, { atualizadoEm: Date.now() });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const removeItem = mutation({
|
||||||
|
args: { itemId: v.id('planejamentoItens') },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await getUsuarioAutenticado(ctx);
|
||||||
|
const it = await ctx.db.get(args.itemId);
|
||||||
|
if (!it) return null;
|
||||||
|
const p = await ctx.db.get(it.planejamentoId);
|
||||||
|
if (!p) throw new Error('Planejamento não encontrado.');
|
||||||
|
if (p.status !== 'rascunho')
|
||||||
|
throw new Error('Apenas planejamentos em rascunho podem ser editados.');
|
||||||
|
await ctx.db.delete(args.itemId);
|
||||||
|
await ctx.db.patch(it.planejamentoId, { atualizadoEm: Date.now() });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const gerarPedidosPorDfd = mutation({
|
||||||
|
args: {
|
||||||
|
planejamentoId: v.id('planejamentosPedidos'),
|
||||||
|
dfds: v.array(
|
||||||
|
v.object({
|
||||||
|
numeroDfd: v.string(),
|
||||||
|
numeroSei: v.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
returns: v.array(v.id('pedidos')),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getUsuarioAutenticado(ctx);
|
||||||
|
if (!user.funcionarioId) {
|
||||||
|
throw new Error('Usuário não vinculado a um funcionário.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const planejamento = await ctx.db.get(args.planejamentoId);
|
||||||
|
if (!planejamento) throw new Error('Planejamento não encontrado.');
|
||||||
|
if (planejamento.status !== 'rascunho') {
|
||||||
|
throw new Error('Este planejamento não está em rascunho.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await ctx.db
|
||||||
|
.query('planejamentoItens')
|
||||||
|
.withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', args.planejamentoId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
throw new Error('Adicione ao menos um item antes de gerar pedidos.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const itensSemDfd = items.filter((i) => !i.numeroDfd || !i.numeroDfd.trim());
|
||||||
|
if (itensSemDfd.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Existem ${itensSemDfd.length} item(ns) sem DFD. Atribua um DFD a todos os itens antes de gerar pedidos.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dfdsPayload = args.dfds.map((d) => ({
|
||||||
|
numeroDfd: d.numeroDfd.trim(),
|
||||||
|
numeroSei: d.numeroSei.trim()
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (dfdsPayload.length === 0) {
|
||||||
|
throw new Error('Informe ao menos um DFD para gerar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const d of dfdsPayload) {
|
||||||
|
if (!d.numeroDfd) throw new Error('DFD inválido.');
|
||||||
|
if (!d.numeroSei) throw new Error(`Informe o número SEI para o DFD ${d.numeroDfd}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar que todos os DFDs existem nos itens
|
||||||
|
const dfdsFromItems = new Set(items.map((i) => (i.numeroDfd as string).trim()));
|
||||||
|
for (const d of dfdsPayload) {
|
||||||
|
if (!dfdsFromItems.has(d.numeroDfd)) {
|
||||||
|
throw new Error(`DFD ${d.numeroDfd} não existe nos itens do planejamento.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evitar duplicidade de DFD no payload
|
||||||
|
const payloadSet = new Set<string>();
|
||||||
|
for (const d of dfdsPayload) {
|
||||||
|
if (payloadSet.has(d.numeroDfd)) throw new Error(`DFD duplicado no envio: ${d.numeroDfd}.`);
|
||||||
|
payloadSet.add(d.numeroDfd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garantir que será gerado 1 pedido para CADA DFD existente nos itens
|
||||||
|
if (payloadSet.size !== dfdsFromItems.size) {
|
||||||
|
const missing = [...dfdsFromItems].filter((d) => !payloadSet.has(d));
|
||||||
|
throw new Error(`Informe o número SEI para todos os DFDs. Faltando: ${missing.join(', ')}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Não permitir gerar se algum item já tiver sido movido para pedido
|
||||||
|
const jaMovidos = items.filter((i) => i.pedidoId);
|
||||||
|
if (jaMovidos.length > 0) {
|
||||||
|
throw new Error('Este planejamento já possui itens vinculados a pedidos.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const pedidoIds: Id<'pedidos'>[] = [];
|
||||||
|
|
||||||
|
for (const dfd of dfdsPayload) {
|
||||||
|
// Criar pedido em rascunho (similar a pedidos.create)
|
||||||
|
const pedidoId = await ctx.db.insert('pedidos', {
|
||||||
|
numeroSei: dfd.numeroSei,
|
||||||
|
numeroDfd: dfd.numeroDfd,
|
||||||
|
status: 'em_rascunho',
|
||||||
|
criadoPor: user._id,
|
||||||
|
criadoEm: now,
|
||||||
|
atualizadoEm: now
|
||||||
|
});
|
||||||
|
|
||||||
|
pedidoIds.push(pedidoId);
|
||||||
|
|
||||||
|
await ctx.db.insert('historicoPedidos', {
|
||||||
|
pedidoId,
|
||||||
|
usuarioId: user._id,
|
||||||
|
acao: 'criacao',
|
||||||
|
detalhes: JSON.stringify({ numeroSei: dfd.numeroSei, numeroDfd: dfd.numeroDfd }),
|
||||||
|
data: now
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.insert('historicoPedidos', {
|
||||||
|
pedidoId,
|
||||||
|
usuarioId: user._id,
|
||||||
|
acao: 'gerado_de_planejamento',
|
||||||
|
detalhes: JSON.stringify({ planejamentoId: args.planejamentoId, numeroDfd: dfd.numeroDfd }),
|
||||||
|
data: now
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.insert('planejamentoPedidosLinks', {
|
||||||
|
planejamentoId: args.planejamentoId,
|
||||||
|
numeroDfd: dfd.numeroDfd,
|
||||||
|
pedidoId,
|
||||||
|
criadoEm: now
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mover itens deste DFD para o pedido
|
||||||
|
const itensDfd = items.filter((i) => (i.numeroDfd as string).trim() === dfd.numeroDfd);
|
||||||
|
for (const it of itensDfd) {
|
||||||
|
// Criar item real diretamente no pedido (sem etapa de conversão)
|
||||||
|
await ctx.db.insert('objetoItems', {
|
||||||
|
pedidoId,
|
||||||
|
objetoId: it.objetoId,
|
||||||
|
ataId: undefined,
|
||||||
|
acaoId: undefined,
|
||||||
|
valorEstimado: it.valorEstimado,
|
||||||
|
quantidade: it.quantidade,
|
||||||
|
adicionadoPor: user.funcionarioId,
|
||||||
|
criadoEm: now
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.insert('historicoPedidos', {
|
||||||
|
pedidoId,
|
||||||
|
usuarioId: user._id,
|
||||||
|
acao: 'adicao_item',
|
||||||
|
detalhes: JSON.stringify({
|
||||||
|
objetoId: it.objetoId,
|
||||||
|
valor: it.valorEstimado,
|
||||||
|
quantidade: it.quantidade,
|
||||||
|
acaoId: null,
|
||||||
|
ataId: null,
|
||||||
|
modalidade: null,
|
||||||
|
origem: { planejamentoId: args.planejamentoId }
|
||||||
|
}),
|
||||||
|
data: now
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.patch(it._id, { pedidoId, atualizadoEm: Date.now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.planejamentoId, { status: 'gerado', atualizadoEm: Date.now() });
|
||||||
|
|
||||||
|
return pedidoIds;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import { funcionariosTables } from './tables/funcionarios';
|
|||||||
import { licencasTables } from './tables/licencas';
|
import { licencasTables } from './tables/licencas';
|
||||||
import { objetosTables } from './tables/objetos';
|
import { objetosTables } from './tables/objetos';
|
||||||
import { pedidosTables } from './tables/pedidos';
|
import { pedidosTables } from './tables/pedidos';
|
||||||
|
import { planejamentosTables } from './tables/planejamentos';
|
||||||
import { pontoTables } from './tables/ponto';
|
import { pontoTables } from './tables/ponto';
|
||||||
import { securityTables } from './tables/security';
|
import { securityTables } from './tables/security';
|
||||||
import { setoresTables } from './tables/setores';
|
import { setoresTables } from './tables/setores';
|
||||||
@@ -42,6 +43,7 @@ export default defineSchema({
|
|||||||
...securityTables,
|
...securityTables,
|
||||||
...pontoTables,
|
...pontoTables,
|
||||||
...pedidosTables,
|
...pedidosTables,
|
||||||
|
...planejamentosTables,
|
||||||
...objetosTables,
|
...objetosTables,
|
||||||
...atasTables,
|
...atasTables,
|
||||||
...lgpdTables
|
...lgpdTables
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const atasTables = {
|
|||||||
numero: v.string(),
|
numero: v.string(),
|
||||||
dataInicio: v.optional(v.string()),
|
dataInicio: v.optional(v.string()),
|
||||||
dataFim: v.optional(v.string()),
|
dataFim: v.optional(v.string()),
|
||||||
|
dataProrrogacao: v.optional(v.string()),
|
||||||
empresaId: v.id('empresas'),
|
empresaId: v.id('empresas'),
|
||||||
numeroSei: v.string(),
|
numeroSei: v.string(),
|
||||||
criadoPor: v.id('usuarios'),
|
criadoPor: v.id('usuarios'),
|
||||||
@@ -18,10 +19,16 @@ export const atasTables = {
|
|||||||
|
|
||||||
atasObjetos: defineTable({
|
atasObjetos: defineTable({
|
||||||
ataId: v.id('atas'),
|
ataId: v.id('atas'),
|
||||||
objetoId: v.id('objetos')
|
objetoId: v.id('objetos'),
|
||||||
|
// Configuração de limite de uso por (ataId, objetoId)
|
||||||
|
quantidadeTotal: v.optional(v.number()),
|
||||||
|
limitePercentual: v.optional(v.number()), // padrão lógico: 50
|
||||||
|
// Controle transacional para evitar corrida; se ausente, pode ser inicializado via rebuild.
|
||||||
|
quantidadeUsada: v.optional(v.number())
|
||||||
})
|
})
|
||||||
.index('by_ataId', ['ataId'])
|
.index('by_ataId', ['ataId'])
|
||||||
.index('by_objetoId', ['objetoId']),
|
.index('by_objetoId', ['objetoId'])
|
||||||
|
.index('by_ataId_and_objetoId', ['ataId', 'objetoId']),
|
||||||
|
|
||||||
atasDocumentos: defineTable({
|
atasDocumentos: defineTable({
|
||||||
ataId: v.id('atas'),
|
ataId: v.id('atas'),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { v } from 'convex/values';
|
|||||||
export const pedidosTables = {
|
export const pedidosTables = {
|
||||||
pedidos: defineTable({
|
pedidos: defineTable({
|
||||||
numeroSei: v.optional(v.string()),
|
numeroSei: v.optional(v.string()),
|
||||||
|
numeroDfd: v.optional(v.string()),
|
||||||
status: v.union(
|
status: v.union(
|
||||||
v.literal('em_rascunho'),
|
v.literal('em_rascunho'),
|
||||||
v.literal('aguardando_aceite'),
|
v.literal('aguardando_aceite'),
|
||||||
@@ -16,23 +17,31 @@ export const pedidosTables = {
|
|||||||
criadoPor: v.id('usuarios'),
|
criadoPor: v.id('usuarios'),
|
||||||
aceitoPor: v.optional(v.id('funcionarios')),
|
aceitoPor: v.optional(v.id('funcionarios')),
|
||||||
descricaoAjuste: v.optional(v.string()), // Required when status is 'precisa_ajustes'
|
descricaoAjuste: v.optional(v.string()), // Required when status is 'precisa_ajustes'
|
||||||
|
concluidoEm: v.optional(v.number()),
|
||||||
criadoEm: v.number(),
|
criadoEm: v.number(),
|
||||||
atualizadoEm: v.number()
|
atualizadoEm: v.number()
|
||||||
})
|
})
|
||||||
.index('by_numeroSei', ['numeroSei'])
|
.index('by_numeroSei', ['numeroSei'])
|
||||||
|
.index('by_numeroDfd', ['numeroDfd'])
|
||||||
.index('by_status', ['status'])
|
.index('by_status', ['status'])
|
||||||
.index('by_criadoPor', ['criadoPor']),
|
.index('by_criadoPor', ['criadoPor'])
|
||||||
|
.index('by_aceitoPor', ['aceitoPor'])
|
||||||
|
.index('by_criadoEm', ['criadoEm'])
|
||||||
|
.index('by_concluidoEm', ['concluidoEm']),
|
||||||
|
|
||||||
objetoItems: defineTable({
|
objetoItems: defineTable({
|
||||||
pedidoId: v.id('pedidos'),
|
pedidoId: v.id('pedidos'),
|
||||||
objetoId: v.id('objetos'), // was produtoId
|
objetoId: v.id('objetos'), // was produtoId
|
||||||
ataId: v.optional(v.id('atas')),
|
ataId: v.optional(v.id('atas')),
|
||||||
acaoId: v.optional(v.id('acoes')), // Moved from pedidos
|
acaoId: v.optional(v.id('acoes')), // Moved from pedidos
|
||||||
modalidade: v.union(
|
// Opcional: permite criar itens sem definir modalidade upfront (ex: geração via planejamento)
|
||||||
|
modalidade: v.optional(
|
||||||
|
v.union(
|
||||||
v.literal('dispensa'),
|
v.literal('dispensa'),
|
||||||
v.literal('inexgibilidade'),
|
v.literal('inexgibilidade'),
|
||||||
v.literal('adesao'),
|
v.literal('adesao'),
|
||||||
v.literal('consumo')
|
v.literal('consumo')
|
||||||
|
)
|
||||||
),
|
),
|
||||||
valorEstimado: v.string(),
|
valorEstimado: v.string(),
|
||||||
valorReal: v.optional(v.string()),
|
valorReal: v.optional(v.string()),
|
||||||
@@ -42,6 +51,7 @@ export const pedidosTables = {
|
|||||||
})
|
})
|
||||||
.index('by_pedidoId', ['pedidoId'])
|
.index('by_pedidoId', ['pedidoId'])
|
||||||
.index('by_objetoId', ['objetoId'])
|
.index('by_objetoId', ['objetoId'])
|
||||||
|
.index('by_ataId_and_objetoId', ['ataId', 'objetoId'])
|
||||||
.index('by_adicionadoPor', ['adicionadoPor'])
|
.index('by_adicionadoPor', ['adicionadoPor'])
|
||||||
.index('by_acaoId', ['acaoId']),
|
.index('by_acaoId', ['acaoId']),
|
||||||
|
|
||||||
@@ -70,5 +80,37 @@ export const pedidosTables = {
|
|||||||
})
|
})
|
||||||
.index('by_pedidoId', ['pedidoId'])
|
.index('by_pedidoId', ['pedidoId'])
|
||||||
.index('by_usuarioId', ['usuarioId'])
|
.index('by_usuarioId', ['usuarioId'])
|
||||||
.index('by_data', ['data'])
|
.index('by_data', ['data']),
|
||||||
|
|
||||||
|
// Documentos anexados diretamente ao pedido (ilimitado)
|
||||||
|
pedidoDocumentos: defineTable({
|
||||||
|
pedidoId: v.id('pedidos'),
|
||||||
|
descricao: v.string(),
|
||||||
|
nome: v.string(),
|
||||||
|
storageId: v.id('_storage'),
|
||||||
|
tipo: v.string(), // MIME type
|
||||||
|
tamanho: v.number(), // bytes
|
||||||
|
criadoPor: v.id('funcionarios'),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
origemSolicitacaoId: v.optional(v.id('solicitacoesItens'))
|
||||||
|
})
|
||||||
|
.index('by_pedidoId', ['pedidoId'])
|
||||||
|
.index('by_criadoPor', ['criadoPor'])
|
||||||
|
.index('by_origemSolicitacaoId', ['origemSolicitacaoId']),
|
||||||
|
|
||||||
|
// Documentos anexados a uma solicitação (somente solicitante; pode ter mais de um)
|
||||||
|
solicitacoesItensDocumentos: defineTable({
|
||||||
|
requestId: v.id('solicitacoesItens'),
|
||||||
|
pedidoId: v.id('pedidos'),
|
||||||
|
descricao: v.string(),
|
||||||
|
nome: v.string(),
|
||||||
|
storageId: v.id('_storage'),
|
||||||
|
tipo: v.string(), // MIME type
|
||||||
|
tamanho: v.number(), // bytes
|
||||||
|
criadoPor: v.id('funcionarios'),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_requestId', ['requestId'])
|
||||||
|
.index('by_pedidoId', ['pedidoId'])
|
||||||
|
.index('by_criadoPor', ['criadoPor'])
|
||||||
};
|
};
|
||||||
|
|||||||
45
packages/backend/convex/tables/planejamentos.ts
Normal file
45
packages/backend/convex/tables/planejamentos.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const planejamentosTables = {
|
||||||
|
planejamentosPedidos: defineTable({
|
||||||
|
titulo: v.string(),
|
||||||
|
descricao: v.string(),
|
||||||
|
// Armazenar como yyyy-MM-dd para facilitar input type="date" no frontend.
|
||||||
|
data: v.string(),
|
||||||
|
responsavelId: v.id('funcionarios'),
|
||||||
|
acaoId: v.optional(v.id('acoes')),
|
||||||
|
status: v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado')),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_responsavelId', ['responsavelId'])
|
||||||
|
.index('by_status', ['status'])
|
||||||
|
.index('by_criadoEm', ['criadoEm']),
|
||||||
|
|
||||||
|
planejamentoItens: defineTable({
|
||||||
|
planejamentoId: v.id('planejamentosPedidos'),
|
||||||
|
// Opcional no cadastro; obrigatório para gerar pedidos.
|
||||||
|
numeroDfd: v.optional(v.string()),
|
||||||
|
objetoId: v.id('objetos'),
|
||||||
|
quantidade: v.number(),
|
||||||
|
valorEstimado: v.string(),
|
||||||
|
// Preenchido após a geração (itens foram materializados no pedido).
|
||||||
|
pedidoId: v.optional(v.id('pedidos')),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_planejamentoId', ['planejamentoId'])
|
||||||
|
.index('by_planejamentoId_and_numeroDfd', ['planejamentoId', 'numeroDfd']),
|
||||||
|
|
||||||
|
planejamentoPedidosLinks: defineTable({
|
||||||
|
planejamentoId: v.id('planejamentosPedidos'),
|
||||||
|
numeroDfd: v.string(),
|
||||||
|
pedidoId: v.id('pedidos'),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_planejamentoId', ['planejamentoId'])
|
||||||
|
.index('by_pedidoId', ['pedidoId'])
|
||||||
|
.index('by_planejamentoId_and_numeroDfd', ['planejamentoId', 'numeroDfd'])
|
||||||
|
};
|
||||||
@@ -63,3 +63,69 @@ export function formatarDataBR(date: Date | string): string {
|
|||||||
return `${day}/${month}/${year}`;
|
return `${day}/${month}/${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pad2(n: number): string {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna a data atual (UTC) no formato YYYY-MM-DD.
|
||||||
|
* Útil para comparações lexicográficas com strings YYYY-MM-DD persistidas no banco.
|
||||||
|
*/
|
||||||
|
export function getTodayYMD(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const y = now.getUTCFullYear();
|
||||||
|
const m = now.getUTCMonth() + 1;
|
||||||
|
const d = now.getUTCDate();
|
||||||
|
return `${y}-${pad2(m)}-${pad2(d)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soma meses (calendário) a uma data YYYY-MM-DD, mantendo o dia quando possível,
|
||||||
|
* e fazendo clamp para o último dia do mês quando necessário.
|
||||||
|
*
|
||||||
|
* Ex.: 2025-03-31 + (-1) mês => 2025-02-28 (ou 29 em ano bissexto)
|
||||||
|
*/
|
||||||
|
export function addMonthsClampedYMD(dateString: string, deltaMonths: number): string {
|
||||||
|
const base = parseLocalDate(dateString); // UTC midnight
|
||||||
|
const year = base.getUTCFullYear();
|
||||||
|
const monthIndex = base.getUTCMonth(); // 0..11
|
||||||
|
const day = base.getUTCDate();
|
||||||
|
|
||||||
|
const totalMonths = monthIndex + deltaMonths;
|
||||||
|
const newYear = year + Math.floor(totalMonths / 12);
|
||||||
|
let newMonthIndex = totalMonths % 12;
|
||||||
|
if (newMonthIndex < 0) {
|
||||||
|
newMonthIndex += 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Último dia do mês alvo
|
||||||
|
const lastDay = new Date(Date.UTC(newYear, newMonthIndex + 1, 0)).getUTCDate();
|
||||||
|
const newDay = Math.min(day, lastDay);
|
||||||
|
|
||||||
|
return `${newYear}-${pad2(newMonthIndex + 1)}-${pad2(newDay)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retorna o maior (mais recente) entre duas datas YYYY-MM-DD (lexicograficamente).
|
||||||
|
* Se uma delas for null/undefined, retorna a outra.
|
||||||
|
*/
|
||||||
|
export function maxYMD(a?: string | null, b?: string | null): string | null {
|
||||||
|
if (!a && !b) return null;
|
||||||
|
if (!a) return b ?? null;
|
||||||
|
if (!b) return a;
|
||||||
|
return a >= b ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checa se `date` está dentro do intervalo [inicio..fim], onde
|
||||||
|
* `inicio` e `fim` são YYYY-MM-DD (ou null para aberto).
|
||||||
|
*/
|
||||||
|
export function isWithinRangeYMD(
|
||||||
|
date: string,
|
||||||
|
inicio?: string | null,
|
||||||
|
fim?: string | null
|
||||||
|
): boolean {
|
||||||
|
const start = inicio ?? '0000-01-01';
|
||||||
|
const end = fim ?? '9999-12-31';
|
||||||
|
return start <= date && date <= end;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user