@@ -108,11 +108,26 @@
|
||||
link: '/pedidos',
|
||||
permission: { recurso: 'pedidos', acao: 'listar' },
|
||||
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',
|
||||
link: '/pedidos',
|
||||
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',
|
||||
@@ -158,7 +173,7 @@
|
||||
submenus: [
|
||||
{
|
||||
label: 'Meus Processos',
|
||||
link: '/fluxos/meus-processos',
|
||||
link: '/fluxos',
|
||||
permission: { recurso: 'fluxos_instancias', acao: 'listar' }
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
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 { formatDistanceToNow } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
@@ -18,9 +20,10 @@
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let usuarioId = $derived((currentUser?.data?._id as Id<'usuarios'> | undefined) ?? null);
|
||||
let notificacoesFerias = $state<
|
||||
Array<{
|
||||
_id: string;
|
||||
_id: Id<'notificacoesFerias'>;
|
||||
mensagem: string;
|
||||
tipo: string;
|
||||
_creationTime: number;
|
||||
@@ -28,7 +31,7 @@
|
||||
>([]);
|
||||
let notificacoesAusencias = $state<
|
||||
Array<{
|
||||
_id: string;
|
||||
_id: Id<'notificacoesAusencias'>;
|
||||
mensagem: string;
|
||||
tipo: string;
|
||||
_creationTime: number;
|
||||
@@ -47,51 +50,47 @@
|
||||
// Separar notificações lidas e não lidas
|
||||
let notificacoesNaoLidas = $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
|
||||
$effect(() => {
|
||||
const totalNotificacoes =
|
||||
count + (notificacoesFerias?.length || 0) + (notificacoesAusencias?.length || 0);
|
||||
notificacoesCount.set(totalNotificacoes);
|
||||
$notificacoesCount = totalNotificacoes;
|
||||
});
|
||||
|
||||
// Buscar notificações de férias
|
||||
async function buscarNotificacoesFerias() {
|
||||
async function buscarNotificacoesFerias(id: Id<'usuarios'> | null) {
|
||||
try {
|
||||
const usuarioId = currentUser?.data?._id;
|
||||
if (usuarioId) {
|
||||
const notifsFerias = await client.query(api.ferias.obterNotificacoesNaoLidas, {
|
||||
usuarioId
|
||||
});
|
||||
notificacoesFerias = notifsFerias || [];
|
||||
}
|
||||
if (!id) return;
|
||||
const notifsFerias = await client.query(api.ferias.obterNotificacoesNaoLidas, {
|
||||
usuarioId: id
|
||||
});
|
||||
notificacoesFerias = notifsFerias || [];
|
||||
} catch (e) {
|
||||
console.error('Erro ao buscar notificações de férias:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar notificações de ausências
|
||||
async function buscarNotificacoesAusencias() {
|
||||
async function buscarNotificacoesAusencias(id: Id<'usuarios'> | null) {
|
||||
try {
|
||||
const usuarioId = currentUser?.data?._id;
|
||||
if (usuarioId) {
|
||||
try {
|
||||
const notifsAusencias = await client.query(api.ausencias.obterNotificacoesNaoLidas, {
|
||||
usuarioId
|
||||
});
|
||||
notificacoesAusencias = notifsAusencias || [];
|
||||
} catch (queryError: unknown) {
|
||||
// Silenciar erros de timeout e função não encontrada
|
||||
const errorMessage =
|
||||
queryError instanceof Error ? queryError.message : String(queryError);
|
||||
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
|
||||
const isFunctionNotFound = errorMessage.includes('Could not find public function');
|
||||
if (!id) return;
|
||||
try {
|
||||
const notifsAusencias = await client.query(api.ausencias.obterNotificacoesNaoLidas, {
|
||||
usuarioId: id
|
||||
});
|
||||
notificacoesAusencias = notifsAusencias || [];
|
||||
} catch (queryError: unknown) {
|
||||
// Silenciar erros de timeout e função não encontrada
|
||||
const errorMessage = queryError instanceof Error ? queryError.message : String(queryError);
|
||||
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
|
||||
const isFunctionNotFound = errorMessage.includes('Could not find public function');
|
||||
|
||||
if (!isTimeout && !isFunctionNotFound) {
|
||||
console.error('Erro ao buscar notificações de ausências:', queryError);
|
||||
}
|
||||
notificacoesAusencias = [];
|
||||
if (!isTimeout && !isFunctionNotFound) {
|
||||
console.error('Erro ao buscar notificações de ausências:', queryError);
|
||||
}
|
||||
notificacoesAusencias = [];
|
||||
}
|
||||
} catch (e) {
|
||||
// Erro geral - silenciar se for sobre função não encontrada ou timeout
|
||||
@@ -106,13 +105,15 @@
|
||||
}
|
||||
|
||||
// Atualizar notificações periodicamente
|
||||
$effect(() => {
|
||||
buscarNotificacoesFerias();
|
||||
buscarNotificacoesAusencias();
|
||||
onMount(() => {
|
||||
void buscarNotificacoesFerias(usuarioId);
|
||||
void buscarNotificacoesAusencias(usuarioId);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
buscarNotificacoesFerias();
|
||||
buscarNotificacoesAusencias();
|
||||
void buscarNotificacoesFerias(usuarioId);
|
||||
void buscarNotificacoesAusencias(usuarioId);
|
||||
}, 30000); // A cada 30s
|
||||
|
||||
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() {
|
||||
limpandoNotificacoes = true;
|
||||
try {
|
||||
await client.mutation(api.chat.limparTodasNotificacoes, {});
|
||||
await buscarNotificacoesFerias();
|
||||
await buscarNotificacoesAusencias();
|
||||
await buscarNotificacoesFerias(usuarioId);
|
||||
await buscarNotificacoesAusencias(usuarioId);
|
||||
} catch (error) {
|
||||
console.error('Erro ao limpar notificações:', error);
|
||||
} finally {
|
||||
@@ -162,8 +145,8 @@
|
||||
limpandoNotificacoes = true;
|
||||
try {
|
||||
await client.mutation(api.chat.limparNotificacoesNaoLidas, {});
|
||||
await buscarNotificacoesFerias();
|
||||
await buscarNotificacoesAusencias();
|
||||
await buscarNotificacoesFerias(usuarioId);
|
||||
await buscarNotificacoesAusencias(usuarioId);
|
||||
} catch (error) {
|
||||
console.error('Erro ao limpar notificações não lidas:', error);
|
||||
} finally {
|
||||
@@ -173,24 +156,24 @@
|
||||
|
||||
async function handleClickNotificacao(notificacaoId: string) {
|
||||
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, {
|
||||
notificacaoId: notificacaoId
|
||||
});
|
||||
await buscarNotificacoesFerias();
|
||||
await buscarNotificacoesFerias(usuarioId);
|
||||
// Redirecionar para a página de férias
|
||||
window.location.href = '/recursos-humanos/ferias';
|
||||
}
|
||||
|
||||
async function handleClickNotificacaoAusencias(notificacaoId: string) {
|
||||
async function handleClickNotificacaoAusencias(notificacaoId: Id<'notificacoesAusencias'>) {
|
||||
await client.mutation(api.ausencias.marcarComoLida, {
|
||||
notificacaoId: notificacaoId
|
||||
});
|
||||
await buscarNotificacoesAusencias();
|
||||
await buscarNotificacoesAusencias(usuarioId);
|
||||
// Redirecionar para a página de perfil na aba de ausências
|
||||
window.location.href = '/perfil?aba=minhas-ausencias';
|
||||
}
|
||||
@@ -204,19 +187,19 @@
|
||||
}
|
||||
|
||||
// Fechar popup ao clicar fora ou pressionar Escape
|
||||
$effect(() => {
|
||||
if (!modalOpen) return;
|
||||
|
||||
onMount(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (!modalOpen) return;
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.notification-popup') && !target.closest('.notification-bell')) {
|
||||
modalOpen = false;
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (!modalOpen) return;
|
||||
if (event.key === 'Escape') {
|
||||
modalOpen = false;
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,56 +213,32 @@
|
||||
</script>
|
||||
|
||||
<div class="notification-bell relative">
|
||||
<!-- Botão de Notificação ULTRA MODERNO (igual ao perfil) -->
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
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;"
|
||||
>
|
||||
<!-- Botão de Notificação (padrão do tema) -->
|
||||
<div class="indicator">
|
||||
{#if totalCount > 0}
|
||||
<span class="indicator-item badge badge-error badge-sm">
|
||||
{totalCount > 9 ? '9+' : totalCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Popup Flutuante de Notificações -->
|
||||
{#if modalOpen}
|
||||
<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;"
|
||||
>
|
||||
<!-- Header -->
|
||||
@@ -310,7 +269,7 @@
|
||||
Limpar todas
|
||||
</button>
|
||||
{/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" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -439,17 +398,17 @@
|
||||
<!-- Notificações de Férias -->
|
||||
{#if notificacoesFerias.length > 0}
|
||||
<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)}
|
||||
<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)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Ícone -->
|
||||
<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>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
@@ -464,7 +423,7 @@
|
||||
|
||||
<!-- Badge -->
|
||||
<div class="shrink-0">
|
||||
<div class="badge badge-primary badge-xs"></div>
|
||||
<div class="badge badge-secondary badge-xs"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -475,17 +434,17 @@
|
||||
<!-- Notificações de Ausências -->
|
||||
{#if notificacoesAusencias.length > 0}
|
||||
<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)}
|
||||
<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)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Ícone -->
|
||||
<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>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
@@ -539,28 +498,6 @@
|
||||
</div>
|
||||
|
||||
<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 {
|
||||
0%,
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user