Merge remote-tracking branch 'origin/master' into ajustes_final_etapa1

This commit is contained in:
2025-12-18 14:58:03 -03:00
44 changed files with 7748 additions and 1741 deletions

View File

@@ -2,6 +2,8 @@
import { resolve } from '$app/paths';
import logo from '$lib/assets/logo_governo_PE.png';
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
import { aplicarTemaDaisyUI } from '$lib/utils/temas';
type HeaderProps = {
left?: Snippet;
@@ -9,6 +11,43 @@
};
const { left, right }: HeaderProps = $props();
let themeSelectEl: HTMLSelectElement | null = null;
function safeGetThemeLS(): string | null {
try {
const t = localStorage.getItem('theme');
return t && t.trim() ? t : null;
} catch {
return null;
}
}
onMount(() => {
const persisted = safeGetThemeLS();
if (persisted) {
// Sincroniza UI + HTML com o valor persistido (evita select ficar "aqua" indevido)
if (themeSelectEl && themeSelectEl.value !== persisted) {
themeSelectEl.value = persisted;
}
aplicarTemaDaisyUI(persisted);
}
});
function onThemeChange(e: Event) {
const nextValue = (e.currentTarget as HTMLSelectElement | null)?.value ?? null;
// Se o theme-change não atualizar (caso comum após login/logout),
// garantimos aqui a persistência + aplicação imediata.
if (nextValue) {
try {
localStorage.setItem('theme', nextValue);
} catch {
// ignore
}
aplicarTemaDaisyUI(nextValue);
}
}
</script>
<header
@@ -36,9 +75,11 @@
<div class="flex items-center gap-2">
<select
bind:this={themeSelectEl}
class="select select-sm bg-base-100 border-base-300 w-40"
aria-label="Selecionar tema"
data-choose-theme
onchange={onThemeChange}
>
<option value="aqua">Aqua</option>
<option value="sgse-blue">Azul</option>

View File

@@ -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' }
},
{

View File

@@ -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% {

View File

@@ -40,9 +40,6 @@
if (result.error) {
console.error('Sign out error:', result.error);
}
// Resetar tema para padrão ao fazer logout
const { aplicarTemaPadrao } = await import('$lib/utils/temas');
aplicarTemaPadrao();
goto(resolve('/home'));
}
</script>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,128 @@
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
import ShineEffect from '$lib/components/ShineEffect.svelte';
type Size = 'sm' | 'md' | 'lg' | 'icon';
type Variant = 'primary' | 'secondary' | 'ghost' | 'outline' | 'danger' | 'link';
type Classes = Partial<{
button: string;
content: string;
spinner: string;
}>;
interface Props {
type?: HTMLButtonAttributes['type'];
disabled?: boolean;
loading?: boolean;
loadingText?: string;
size?: Size;
variant?: Variant;
fullWidth?: boolean;
shine?: boolean;
left?: Snippet;
right?: Snippet;
children?: Snippet;
classes?: Classes;
class?: string;
}
let {
type = 'button',
disabled = false,
loading = false,
loadingText,
size = 'md',
variant = 'primary',
fullWidth = false,
shine = false,
left,
right,
children,
classes,
class: className = ''
}: Props = $props();
const isDisabled = $derived(disabled || loading);
const sizeClass = $derived(
size === 'sm'
? 'px-3 py-2 text-sm'
: size === 'lg'
? 'px-5 py-4 text-base'
: size === 'icon'
? 'p-2'
: 'px-4 py-3.5 text-sm'
);
const base =
'relative inline-flex items-center justify-center gap-2 rounded-xl font-bold transition-all duration-300 overflow-hidden';
const variantClass = $derived(
variant === 'primary'
? 'bg-primary hover:bg-primary-focus hover:shadow-primary/25 text-primary-content shadow-lg'
: variant === 'secondary'
? 'bg-base-200 hover:bg-base-300 text-base-content'
: variant === 'ghost'
? 'bg-transparent hover:bg-base-200/60 text-base-content'
: variant === 'outline'
? 'border-base-content/20 hover:bg-base-200/40 text-base-content border'
: variant === 'danger'
? 'bg-error hover:bg-error/90 text-error-content shadow-lg'
: 'bg-transparent hover:underline text-primary px-0 py-0 rounded-none'
);
const widthClass = $derived(fullWidth ? 'w-full' : '');
const disabledClass = 'disabled:cursor-not-allowed disabled:opacity-50';
const buttonClass = $derived(
[base, sizeClass, variantClass, widthClass, disabledClass, classes?.button, className]
.filter(Boolean)
.join(' ')
);
</script>
<button {type} class={buttonClass} disabled={isDisabled}>
<div
class={['relative z-10 flex items-center justify-center gap-2', classes?.content].filter(
Boolean
)}
>
{#if loading}
<span
class={[
'border-primary-content/30 border-t-primary-content h-5 w-5 animate-spin rounded-full border-2',
classes?.spinner
].filter(Boolean)}
></span>
{#if loadingText}
<span>{loadingText}</span>
{/if}
{:else}
{#if left}
<span class="[&_svg]:h-4 [&_svg]:w-4">
{@render left()}
</span>
{/if}
{@render children?.()}
{#if right}
<span class="[&_svg]:h-4 [&_svg]:w-4">
{@render right()}
</span>
{/if}
{/if}
</div>
{#if shine}
<ShineEffect />
{/if}
</button>

View 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>

View 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>

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import { Field } from '@ark-ui/svelte/field';
import type { Snippet } from 'svelte';
import type { HTMLInputAttributes } from 'svelte/elements';
type Size = 'sm' | 'md' | 'lg';
type Variant = 'filled' | 'outline';
type Classes = Partial<{
root: string;
labelRow: string;
label: string;
control: string;
input: string;
helperText: string;
errorText: string;
}>;
interface Props {
id: string;
label: string;
value?: string;
type?: HTMLInputAttributes['type'];
name?: string;
placeholder?: string;
autocomplete?: HTMLInputAttributes['autocomplete'];
disabled?: boolean;
readonly?: boolean;
required?: boolean;
error?: string | null;
helperText?: string | null;
size?: Size;
variant?: Variant;
fullWidth?: boolean;
left?: Snippet;
right?: Snippet;
inputProps?: Omit<
HTMLInputAttributes,
| 'id'
| 'type'
| 'name'
| 'placeholder'
| 'autocomplete'
| 'disabled'
| 'required'
| 'readOnly'
| 'value'
>;
classes?: Classes;
class?: string;
}
let {
id,
label,
value = $bindable(''),
type = 'text',
name,
placeholder = '',
autocomplete,
disabled = false,
readonly = false,
required = false,
error = null,
helperText = null,
size = 'md',
variant = 'filled',
fullWidth = true,
left,
right,
inputProps,
classes,
class: className = ''
}: Props = $props();
const invalid = $derived(!!error);
const hasLeft = $derived(!!left);
const hasRight = $derived(!!right);
const paddingY = $derived(size === 'sm' ? 'py-2.5' : size === 'lg' ? 'py-3.5' : 'py-3');
const paddingX = 'px-4';
const paddingLeft = $derived(hasLeft ? 'pl-11' : '');
const paddingRight = $derived(hasRight ? 'pr-11' : '');
const baseInput =
'border-base-content/10 bg-base-200/25 text-base-content placeholder-base-content/40 focus:border-primary/50 focus:bg-base-200/35 focus:ring-primary/20 w-full rounded-xl border transition-all duration-300 focus:ring-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60';
const variantClass = $derived(variant === 'outline' ? 'bg-transparent' : 'bg-base-200/25');
const inputClass = $derived(
[
baseInput,
variantClass,
fullWidth ? 'w-full' : '',
paddingX,
paddingY,
paddingLeft,
paddingRight,
classes?.input
]
.filter(Boolean)
.join(' ')
);
</script>
<Field.Root {invalid} {required} {disabled} class={['space-y-2', classes?.root, className]}>
<div class={['flex items-center justify-between gap-3', classes?.labelRow].filter(Boolean)}>
<Field.Label
for={id}
class={[
'text-base-content/60 text-xs font-semibold tracking-wider uppercase',
classes?.label
].filter(Boolean)}
>
{label}
</Field.Label>
{@render right?.()}
</div>
<div class={['group relative', classes?.control].filter(Boolean)}>
{#if left}
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<div class="text-base-content/50 [&_svg]:h-4 [&_svg]:w-4">
{@render left()}
</div>
</div>
{/if}
<Field.Input
{id}
{type}
{name}
{placeholder}
{disabled}
{readonly}
{autocomplete}
{required}
bind:value
{...inputProps}
class={inputClass}
/>
{#if right}
<div class="absolute inset-y-0 right-0 flex items-center pr-4">
<div class="text-base-content/70 [&_svg]:h-4 [&_svg]:w-4">
{@render right()}
</div>
</div>
{/if}
</div>
{#if helperText && !error}
<Field.HelperText class={['text-base-content/50 text-sm', classes?.helperText].filter(Boolean)}>
{helperText}
</Field.HelperText>
{/if}
{#if error}
<Field.ErrorText class={['text-error text-sm font-medium', classes?.errorText].filter(Boolean)}>
{error}
</Field.ErrorText>
{/if}
</Field.Root>

View File

@@ -0,0 +1,287 @@
<script lang="ts">
import { Field } from '@ark-ui/svelte/field';
import { Portal } from '@ark-ui/svelte/portal';
import { Select, createListCollection } from '@ark-ui/svelte/select';
import type { Snippet } from 'svelte';
import { Check, ChevronDown, X } from 'lucide-svelte';
type Size = 'sm' | 'md' | 'lg';
type Variant = 'filled' | 'outline';
export interface SelectItem {
label: string;
value: string;
disabled?: boolean;
group?: string;
}
type Classes = Partial<{
root: string;
labelRow: string;
label: string;
control: string;
trigger: string;
content: string;
item: string;
helperText: string;
errorText: string;
}>;
interface Props {
id: string;
label: string;
items: SelectItem[];
value?: string[];
name?: string;
placeholder?: string;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
error?: string | null;
helperText?: string | null;
size?: Size;
variant?: Variant;
fullWidth?: boolean;
multiple?: boolean;
clearable?: boolean;
maxSelected?: number;
positioning?: Select.RootProps<SelectItem>['positioning'];
/** Slot ao lado do label (ex.: link, ação, etc.) */
labelRight?: Snippet;
/** Slot antes do texto no trigger (ex.: ícone) */
triggerLeft?: Snippet;
classes?: Classes;
class?: string;
}
let {
id,
label,
items,
value = $bindable<string[]>([]),
name,
placeholder = 'Selecione...',
disabled = false,
readOnly = false,
required = false,
error = null,
helperText = null,
size = 'md',
variant = 'filled',
fullWidth = true,
multiple = false,
clearable = true,
maxSelected,
positioning,
labelRight,
triggerLeft,
classes,
class: className = ''
}: Props = $props();
const invalid = $derived(!!error);
const hasGroups = $derived(items.some((i) => i.group));
const canClear = $derived(clearable && value.length > 0 && !disabled && !readOnly);
const collection = $derived(
createListCollection<SelectItem>({
items
})
);
const groups = $derived.by(() => {
if (!hasGroups) return [];
const record: Record<string, SelectItem[]> = {};
for (const item of items) {
const key = item.group ?? 'Opções';
const arr = record[key] ?? [];
arr.push(item);
record[key] = arr;
}
return Object.entries(record);
});
const paddingY = $derived(size === 'sm' ? 'py-2.5' : size === 'lg' ? 'py-3.5' : 'py-3');
const paddingX = 'px-4';
const baseTrigger =
'border-base-content/10 bg-base-200/25 text-base-content focus:border-primary/50 focus:bg-base-200/35 focus:ring-primary/20 w-full rounded-xl border transition-all duration-300 focus:ring-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60';
const variantClass = $derived(variant === 'outline' ? 'bg-transparent' : 'bg-base-200/25');
const triggerClass = $derived(
[
baseTrigger,
variantClass,
fullWidth ? 'w-full' : '',
'flex items-center justify-between gap-3 text-left',
paddingX,
paddingY,
classes?.trigger
]
.filter(Boolean)
.join(' ')
);
const contentClass = $derived(
[
'bg-base-100 border-base-200 w-[var(--reference-width)] overflow-hidden rounded-xl border shadow-lg',
'max-h-72',
'z-50',
classes?.content
]
.filter(Boolean)
.join(' ')
);
function handleValueChange(details: { value: string[] }) {
let next = details.value;
if (
typeof maxSelected === 'number' &&
maxSelected >= 0 &&
multiple &&
next.length > maxSelected
) {
next = next.slice(0, maxSelected);
}
value = next;
}
</script>
<Field.Root {invalid} {required} {disabled} class={['space-y-2', classes?.root, className]}>
<div class={['flex items-center justify-between gap-3', classes?.labelRow].filter(Boolean)}>
<Field.Label
for={id}
class={[
'text-base-content/60 text-xs font-semibold tracking-wider uppercase',
classes?.label
].filter(Boolean)}
>
{label}
</Field.Label>
{@render labelRight?.()}
</div>
<Select.Root
{collection}
{disabled}
{required}
{readOnly}
{invalid}
{multiple}
{positioning}
{value}
onValueChange={handleValueChange}
>
<Select.Control class={classes?.control}>
<Select.Trigger {id} class={triggerClass}>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if triggerLeft}
<span class="text-base-content/60 shrink-0 [&_svg]:h-4 [&_svg]:w-4">
{@render triggerLeft()}
</span>
{/if}
<Select.ValueText
{placeholder}
class="text-base-content/90 data-placeholder-shown:text-base-content/40 truncate"
/>
</div>
<div class="flex shrink-0 items-center gap-2">
{#if canClear}
<Select.ClearTrigger
aria-label="Limpar seleção"
class="text-base-content/50 hover:text-base-content/80 inline-flex items-center justify-center rounded-md p-1 transition-colors"
>
<X class="h-4 w-4" />
</Select.ClearTrigger>
{/if}
<Select.Indicator class="text-base-content/60 [&_svg]:h-4 [&_svg]:w-4">
<ChevronDown />
</Select.Indicator>
</div>
</Select.Trigger>
</Select.Control>
<Portal>
<Select.Positioner>
<Select.Content class={contentClass}>
<div class="p-1">
{#if hasGroups}
{#each groups as [groupLabel, groupItems] (groupLabel)}
<Select.ItemGroup class="mb-1 last:mb-0">
<Select.ItemGroupLabel
class="text-base-content/50 px-3 py-2 text-xs font-semibold tracking-wider uppercase"
>
{groupLabel}
</Select.ItemGroupLabel>
{#each groupItems as item (item.value)}
<Select.Item
{item}
class={[
'text-base-content relative flex cursor-pointer items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm',
'data-highlighted:bg-base-200 data-highlighted:text-base-content',
'data-[state=checked]:bg-primary/10',
'data-disabled:cursor-not-allowed data-disabled:opacity-50',
classes?.item
].filter(Boolean)}
>
<Select.ItemText class="min-w-0 flex-1 truncate">{item.label}</Select.ItemText
>
<Select.ItemIndicator class="text-primary shrink-0">
<Check class="h-4 w-4" />
</Select.ItemIndicator>
</Select.Item>
{/each}
</Select.ItemGroup>
{/each}
{:else}
{#each items as item (item.value)}
<Select.Item
{item}
class={[
'text-base-content relative flex cursor-pointer items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm',
'data-highlighted:bg-base-200 data-highlighted:text-base-content',
'data-[state=checked]:bg-primary/10',
'data-disabled:cursor-not-allowed data-disabled:opacity-50',
classes?.item
].filter(Boolean)}
>
<Select.ItemText class="min-w-0 flex-1 truncate">{item.label}</Select.ItemText>
<Select.ItemIndicator class="text-primary shrink-0">
<Check class="h-4 w-4" />
</Select.ItemIndicator>
</Select.Item>
{/each}
{/if}
</div>
</Select.Content>
</Select.Positioner>
</Portal>
<Select.HiddenSelect {name} />
</Select.Root>
{#if helperText && !error}
<Field.HelperText class={['text-base-content/50 text-sm', classes?.helperText].filter(Boolean)}>
{helperText}
</Field.HelperText>
{/if}
{#if error}
<Field.ErrorText class={['text-error text-sm font-medium', classes?.errorText].filter(Boolean)}>
{error}
</Field.ErrorText>
{/if}
</Field.Root>

View 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>

View 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);
}

View 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);
}

View File

@@ -144,6 +144,52 @@ export function obterNomeDaisyUI(id: TemaId | string | null | undefined): string
return temaParaDaisyUI[tema.id] || 'aqua';
}
/**
* Lê o tema persistido pelo `theme-change` (chave "theme") no localStorage.
* Retorna o nome do tema do DaisyUI (ex.: "dark", "light", "aqua", "sgse-blue").
*/
export function obterTemaPersistidoNoLocalStorage(chave: string = 'theme'): string | null {
if (typeof window === 'undefined') return null;
try {
const tema = window.localStorage.getItem(chave);
return tema && tema.trim() ? tema : null;
} catch {
return null;
}
}
/**
* Aplica diretamente um tema do DaisyUI no `<html data-theme="...">`.
* (Não altera o localStorage)
*/
export function aplicarTemaDaisyUI(tema: string): void {
if (typeof document === 'undefined') return;
const htmlElement = document.documentElement;
if (!htmlElement) return;
// Normaliza qualquer estado anterior
htmlElement.removeAttribute('data-theme');
// Evita que `body[data-theme]` sobrescreva o tema do `<html>`
if (document.body) document.body.removeAttribute('data-theme');
htmlElement.setAttribute('data-theme', tema);
// Forçar reflow para garantir que o CSS seja aplicado
void htmlElement.offsetHeight;
}
/**
* Garante que o tema do `<html>` reflita SEMPRE o valor persistido no localStorage.
* Se não houver tema persistido, aplica o tema padrão.
*/
export function aplicarTemaDoLocalStorage(): void {
const temaPersistido = obterTemaPersistidoNoLocalStorage('theme');
if (temaPersistido) {
aplicarTemaDaisyUI(temaPersistido);
return;
}
aplicarTemaPadrao();
}
/**
* Aplicar tema ao documento HTML
* NÃO salva no localStorage - apenas no banco de dados do usuário