diff --git a/apps/web/src/lib/components/AprovarAusencias.svelte b/apps/web/src/lib/components/AprovarAusencias.svelte index fd7279f..b828d5e 100644 --- a/apps/web/src/lib/components/AprovarAusencias.svelte +++ b/apps/web/src/lib/components/AprovarAusencias.svelte @@ -135,47 +135,48 @@
-
-

Aprovar/Reprovar Ausência

-

Analise a solicitação e tome uma decisão

+
+

Aprovar/Reprovar Ausência

+

Analise a solicitação e tome uma decisão

-
+
-
-

-
- +
+

+
+
Funcionário

-
-
-

+

+
+

Nome

-
+
-

+

{solicitacao.funcionario?.nome || 'N/A'}

{#if solicitacao.time} -
-

+

+

Time

{solicitacao.time.nome}
@@ -184,58 +185,58 @@
-
+
-
-

-
- +
+

+
+
Período da Ausência

-
+
-
Data Início
-
+
Data Início
+
{parseLocalDate(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
-
Data Fim
-
+
Data Fim
+
{parseLocalDate(solicitacao.dataFim).toLocaleDateString('pt-BR')}
-
Total de Dias
-
+
Total de Dias
+
{totalDias}
-
dias corridos
+
dias corridos
-
+
-
-

-
- +
+

+
+
Motivo da Ausência

-
-
-

+

+
+

{solicitacao.motivo}

@@ -243,12 +244,12 @@
-
-
- +
+ Status: -
+
{getStatusTexto(solicitacao.status)}
@@ -256,12 +257,12 @@ {#if solicitacao.status === 'aprovado'} -
- +
+
-
Aprovado
+
Aprovado
{#if solicitacao.gestor} -
+
Por: {solicitacao.gestor.nome}
{/if} @@ -275,12 +276,12 @@ {/if} {#if solicitacao.status === 'reprovado'} -
- +
+
-
Reprovado
+
Reprovado
{#if solicitacao.gestor} -
+
Por: {solicitacao.gestor.nome}
{/if} @@ -291,8 +292,8 @@ {/if} {#if solicitacao.motivoReprovacao}
-
Motivo:
-
{solicitacao.motivoReprovacao}
+
Motivo:
+
{solicitacao.motivoReprovacao}
{/if}
@@ -301,21 +302,21 @@ {#if solicitacao.historicoAlteracoes && solicitacao.historicoAlteracoes.length > 0} -
-

-
- +
+

+
+
Histórico de Alterações

-
-
-
+
+
+
{#each solicitacao.historicoAlteracoes as hist} -
- +
+
-
{hist.acao}
+
{hist.acao}
{new Date(hist.data).toLocaleString('pt-BR')}
@@ -330,38 +331,38 @@ {#if erro} -
- - {erro} +
+ + {erro}
{/if} {#if solicitacao.status === 'aguardando_aprovacao'} -
+
@@ -369,14 +370,14 @@ {#if motivoReprovacao !== undefined} -
+
-
{/if} {:else} -
- - Esta solicitação já foi processada. +
+ + Esta solicitação já foi processada.
{/if} -
+
diff --git a/apps/web/src/lib/components/ponto/BancoHorasMensal.svelte b/apps/web/src/lib/components/ponto/BancoHorasMensal.svelte index df03051..6bd5649 100644 --- a/apps/web/src/lib/components/ponto/BancoHorasMensal.svelte +++ b/apps/web/src/lib/components/ponto/BancoHorasMensal.svelte @@ -60,6 +60,36 @@ const historico = $derived(historicoQuery?.data || []); const historicoAlteracoes = $derived(historicoAlteracoesQuery?.data || []); + // Dados para o gráfico de evolução + const chartData = $derived(() => { + if (!historico || historico.length === 0) return null; + + // Ordenar por mês (crescente) + const historicoOrdenado = [...historico].sort((a, b) => { + if (a.mes < b.mes) return -1; + if (a.mes > b.mes) return 1; + return 0; + }); + + return { + labels: historicoOrdenado.map((h) => { + const [ano, mesNum] = h.mes.split('-'); + const data = new Date(parseInt(ano), parseInt(mesNum) - 1, 1); + return data.toLocaleDateString('pt-BR', { month: 'short', year: 'numeric' }); + }), + datasets: [ + { + label: 'Saldo Final (horas)', + data: historicoOrdenado.map((h) => h.saldoFinalMinutos / 60), + borderColor: 'rgb(59, 130, 246)', + backgroundColor: 'rgba(59, 130, 246, 0.1)', + tension: 0.4, + fill: true + } + ] + }; + }); + // Função para formatar mês function formatarMes(mes: string): string { const [ano, mesNum] = mes.split('-'); @@ -476,22 +506,7 @@ {/if} - {#if chartData} -
-
-

- - Evolução do Banco de Horas -

-
- -
-
-
- {/if} - - - {#if chartData} + {#if chartData && historico && historico.length > 0}

diff --git a/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte index 38d5790..a299eda 100644 --- a/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte +++ b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte @@ -54,18 +54,17 @@ erro = 'Usando relógio do PC'; } - // Aplicar GMT offset ao timestamp - // Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática - // Quando GMT ≠ 0, aplicar offset configurado ao timestamp + // Aplicar GMT offset ao timestamp UTC + // O offset é aplicado manualmente, então usamos UTC como base para evitar conversão dupla let timestampAjustado: number; if (gmtOffset !== 0) { - // Aplicar offset configurado + // Aplicar offset configurado ao timestamp UTC timestampAjustado = timestampBase + gmtOffset * 60 * 60 * 1000; } else { // Quando GMT = 0, manter timestamp UTC puro - // O toLocaleTimeString() converterá automaticamente para o timezone local do navegador timestampAjustado = timestampBase; } + // Armazenar o timestamp ajustado (não o Date, para evitar problemas de timezone) tempoAtual = new Date(timestampAjustado); } catch (error) { console.error('Erro ao obter tempo:', error); @@ -96,19 +95,25 @@ }); const horaFormatada = $derived.by(() => { + // Usar UTC como base pois já aplicamos o offset manualmente no timestamp + // Isso evita conversão dupla pelo navegador return tempoAtual.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', - second: '2-digit' + second: '2-digit', + timeZone: 'UTC' // Usar UTC como base pois já aplicamos o offset manualmente }); }); const dataFormatada = $derived.by(() => { + // Usar UTC como base pois já aplicamos o offset manualmente no timestamp + // Isso evita conversão dupla pelo navegador return tempoAtual.toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long', - year: 'numeric' + year: 'numeric', + timeZone: 'UTC' // Usar UTC como base pois já aplicamos o offset manualmente }); }); diff --git a/apps/web/src/routes/(dashboard)/gestao-pessoas/gestao-ausencias/+page.svelte b/apps/web/src/routes/(dashboard)/gestao-pessoas/gestao-ausencias/+page.svelte index bd71c28..8be54d7 100644 --- a/apps/web/src/routes/(dashboard)/gestao-pessoas/gestao-ausencias/+page.svelte +++ b/apps/web/src/routes/(dashboard)/gestao-pessoas/gestao-ausencias/+page.svelte @@ -6,23 +6,61 @@ import AprovarAusencias from '$lib/components/AprovarAusencias.svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { parseLocalDate } from '$lib/utils/datas'; + import jsPDF from 'jspdf'; + import autoTable from 'jspdf-autotable'; + import { format } from 'date-fns'; + import { ptBR } from 'date-fns/locale'; + import ExcelJS from 'exceljs'; + import logoGovPE from '$lib/assets/logo_governo_PE.png'; + import { FileDown, FileSpreadsheet } from 'lucide-svelte'; + import { toast } from 'svelte-sonner'; const client = useConvexClient(); const currentUser = useQuery(api.auth.getCurrentUser, {}); // Buscar TODAS as solicitações de ausências const todasAusenciasQuery = useQuery(api.ausencias.listarTodas, {}); + + // Buscar funcionários para filtro + const funcionariosQuery = useQuery(api.funcionarios.getAll, {}); let filtroStatus = $state('todos'); + let filtroFuncionario = $state(''); + let filtroPeriodoInicio = $state(''); + let filtroPeriodoFim = $state(''); + let gerandoRelatorio = $state(false); let solicitacaoSelecionada = $state | null>(null); const ausencias = $derived(todasAusenciasQuery?.data || []); + const funcionarios = $derived( + Array.isArray(funcionariosQuery?.data) ? funcionariosQuery.data : funcionariosQuery?.data?.data || [] + ); // Filtrar solicitações const ausenciasFiltradas = $derived( ausencias.filter((a) => { // Filtro de status if (filtroStatus !== 'todos' && a.status !== filtroStatus) return false; + + // Filtro por funcionário + if (filtroFuncionario) { + if (a.funcionario?._id !== filtroFuncionario) return false; + } + + // Filtro por período + if (filtroPeriodoInicio) { + const inicioFiltro = new Date(filtroPeriodoInicio); + const inicioAusencia = parseLocalDate(a.dataInicio); + if (inicioAusencia < inicioFiltro) return false; + } + + if (filtroPeriodoFim) { + const fimFiltro = new Date(filtroPeriodoFim); + fimFiltro.setHours(23, 59, 59, 999); // Incluir o dia inteiro + const fimAusencia = parseLocalDate(a.dataFim); + if (fimAusencia > fimFiltro) return false; + } + return true; }) ); @@ -67,6 +105,383 @@ async function recarregar() { solicitacaoSelecionada = null; } + + // Função para gerar PDF + async function gerarPDF() { + gerandoRelatorio = true; + try { + const doc = new jsPDF(); + + // Logo + let yPosition = 20; + try { + const logoImg = new Image(); + logoImg.src = logoGovPE; + await new Promise((resolve, reject) => { + logoImg.onload = () => resolve(); + logoImg.onerror = () => reject(); + setTimeout(() => reject(), 3000); + }); + + const logoWidth = 30; + const aspectRatio = logoImg.height / logoImg.width; + const logoHeight = logoWidth * aspectRatio; + + doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight); + yPosition = Math.max(25, 10 + logoHeight / 2); + } catch (err) { + console.warn('Não foi possível carregar a logo:', err); + } + + // Título + doc.setFontSize(18); + doc.setTextColor(41, 128, 185); + doc.text('RELATÓRIO DE AUSÊNCIAS', 105, yPosition, { align: 'center' }); + + yPosition += 10; + + // Filtros aplicados + doc.setFontSize(10); + doc.setTextColor(0, 0, 0); + let filtrosTexto = 'Filtros aplicados: '; + if (filtroStatus !== 'todos') filtrosTexto += `Status: ${getStatusTexto(filtroStatus)}; `; + if (filtroFuncionario) { + const func = funcionarios.find((f) => f._id === filtroFuncionario); + filtrosTexto += `Funcionário: ${func?.nome || ''}; `; + } + if (filtroPeriodoInicio && filtroPeriodoFim) { + filtrosTexto += `Período: ${format(new Date(filtroPeriodoInicio), 'dd/MM/yyyy', { locale: ptBR })} até ${format(new Date(filtroPeriodoFim), 'dd/MM/yyyy', { locale: ptBR })}; `; + } + + if (filtrosTexto === 'Filtros aplicados: ') { + filtrosTexto = 'Todos os registros'; + } + + doc.text(filtrosTexto, 105, yPosition, { align: 'center', maxWidth: 180 }); + + yPosition += 8; + + // Data de geração + doc.setFontSize(9); + doc.setTextColor(100, 100, 100); + doc.text( + `Gerado em: ${format(new Date(), 'dd/MM/yyyy HH:mm', { locale: ptBR })}`, + 15, + yPosition + ); + + yPosition += 12; + + // Preparar dados para tabela + const dadosTabela: string[][] = ausenciasFiltradas.map((a) => [ + a.funcionario?.nome || '-', + a.funcionario?.matricula || '-', + a.time?.nome || '-', + parseLocalDate(a.dataInicio).toLocaleDateString('pt-BR'), + parseLocalDate(a.dataFim).toLocaleDateString('pt-BR'), + calcularDias(a.dataInicio, a.dataFim).toString(), + a.motivo.substring(0, 40) + (a.motivo.length > 40 ? '...' : ''), + getStatusTexto(a.status) + ]); + + // Tabela + if (dadosTabela.length > 0) { + autoTable(doc, { + startY: yPosition, + head: [ + [ + 'Funcionário', + 'Matrícula', + 'Time', + 'Data Início', + 'Data Fim', + 'Dias', + 'Motivo', + 'Status' + ] + ], + body: dadosTabela, + theme: 'striped', + headStyles: { fillColor: [41, 128, 185], textColor: 255, fontSize: 7, fontStyle: 'bold' }, + bodyStyles: { fontSize: 7 }, + alternateRowStyles: { fillColor: [245, 247, 250] }, + styles: { cellPadding: 1.5, overflow: 'linebreak' }, + margin: { top: yPosition, left: 10, right: 10 }, + tableWidth: 'wrap', + columnStyles: { + 0: { cellWidth: 38, fontSize: 7 }, // Funcionário + 1: { cellWidth: 18, fontSize: 7 }, // Matrícula + 2: { cellWidth: 22, fontSize: 7 }, // Time + 3: { cellWidth: 20, fontSize: 7 }, // Data Início + 4: { cellWidth: 20, fontSize: 7 }, // Data Fim + 5: { cellWidth: 12, fontSize: 7 }, // Dias + 6: { cellWidth: 35, fontSize: 7, overflow: 'linebreak' }, // Motivo + 7: { cellWidth: 18, fontSize: 7 } // Status + } + }); + } else { + doc.setFontSize(12); + doc.setTextColor(100, 100, 100); + doc.text('Nenhum registro encontrado com os filtros aplicados.', 105, yPosition + 20, { + align: 'center' + }); + } + + // Footer em todas as páginas + const pageCount = doc.getNumberOfPages(); + for (let i = 1; i <= pageCount; i++) { + doc.setPage(i); + doc.setFontSize(7); + 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' } + ); + } + + // Salvar + const nomeArquivo = `relatorio-ausencias-${format(new Date(), 'yyyy-MM-dd-HHmm')}.pdf`; + doc.save(nomeArquivo); + toast.success('Relatório PDF gerado com sucesso!'); + } catch (error) { + console.error('Erro ao gerar PDF:', error); + toast.error('Erro ao gerar relatório PDF. Tente novamente.'); + } finally { + gerandoRelatorio = false; + } + } + + // Função para gerar Excel + async function gerarExcel() { + if (ausenciasFiltradas.length === 0) { + toast.error('Não há ausências para exportar com os filtros aplicados.'); + return; + } + + gerandoRelatorio = true; + try { + // Preparar dados + const dados: Array> = ausenciasFiltradas.map((a) => ({ + Funcionário: a.funcionario?.nome || '-', + Matrícula: a.funcionario?.matricula || '-', + Time: a.time?.nome || '-', + 'Data Início': parseLocalDate(a.dataInicio).toLocaleDateString('pt-BR'), + 'Data Fim': parseLocalDate(a.dataFim).toLocaleDateString('pt-BR'), + Dias: calcularDias(a.dataInicio, a.dataFim), + Motivo: a.motivo, + Status: getStatusTexto(a.status), + 'Solicitado em': new Date(a.criadoEm).toLocaleDateString('pt-BR') + })); + + // Criar workbook com ExcelJS + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Relatório de Ausências'); + + // Obter cabeçalhos + const headers = Object.keys(dados[0] || {}); + + // Carregar logo + let logoBuffer: ArrayBuffer | null = null; + try { + const response = await fetch(logoGovPE); + if (response.ok) { + logoBuffer = await response.arrayBuffer(); + } else { + const logoImg = new Image(); + logoImg.crossOrigin = 'anonymous'; + logoImg.src = logoGovPE; + await new Promise((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) { + ctx.drawImage(logoImg, 0, 0); + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error('Falha ao converter imagem')); + }, 'image/png'); + }); + logoBuffer = await blob.arrayBuffer(); + } + } + } catch (err) { + console.warn('Não foi possível carregar a logo:', err); + } + + // Linha 1: Cabeçalho com logo e título + worksheet.mergeCells('A1:B1'); + const logoCell = worksheet.getCell('A1'); + logoCell.alignment = { vertical: 'middle', horizontal: 'left' }; + logoCell.border = { + right: { style: 'thin', color: { argb: 'FFE0E0E0' } } + }; + + // Adicionar logo se disponível + if (logoBuffer) { + const logoId = workbook.addImage({ + buffer: new Uint8Array(logoBuffer), + extension: 'png' + }); + worksheet.addImage(logoId, { + tl: { col: 0, row: 0 }, + ext: { width: 140, height: 55 } + }); + } + + // Mesclar C1 até última coluna para título + const lastCol = String.fromCharCode(65 + headers.length - 1); + worksheet.mergeCells(`C1:${lastCol}1`); + const titleCell = worksheet.getCell('C1'); + titleCell.value = 'RELATÓRIO DE AUSÊNCIAS'; + titleCell.font = { bold: true, size: 18, color: { argb: 'FF2980B9' } }; + titleCell.alignment = { vertical: 'middle', horizontal: 'center' }; + + // Ajustar altura da linha 1 para acomodar a logo + worksheet.getRow(1).height = 60; + + // Linha 2: Cabeçalhos da tabela + headers.forEach((header, index) => { + const cell = worksheet.getCell(2, index + 1); + cell.value = header; + cell.font = { bold: true, size: 11, color: { argb: 'FFFFFFFF' } }; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF2980B9' } + }; + cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; + cell.border = { + top: { style: 'thin', color: { argb: 'FF000000' } }, + bottom: { style: 'thin', color: { argb: 'FF000000' } }, + left: { style: 'thin', color: { argb: 'FF000000' } }, + right: { style: 'thin', color: { argb: 'FF000000' } } + }; + }); + + // Ajustar altura da linha 2 + worksheet.getRow(2).height = 25; + + // Linhas 3+: Dados + dados.forEach((rowData, rowIndex) => { + const row = rowIndex + 3; + headers.forEach((header, colIndex) => { + const cell = worksheet.getCell(row, colIndex + 1); + cell.value = rowData[header]; + + // Cor de fundo alternada (zebra striping) + const isEvenRow = rowIndex % 2 === 1; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: isEvenRow ? 'FFF8F9FA' : 'FFFFFFFF' } + }; + + // Alinhamento + let alignment: 'left' | 'center' | 'right' = 'left'; + if ( + header === 'Dias' || + header === 'Data Início' || + header === 'Data Fim' || + header === 'Status' || + header === 'Solicitado em' + ) { + alignment = 'center'; + } + cell.alignment = { vertical: 'middle', horizontal: alignment, wrapText: true }; + + // Fonte + cell.font = { size: 10, color: { argb: 'FF000000' } }; + + // Bordas + cell.border = { + top: { style: 'thin', color: { argb: 'FFE0E0E0' } }, + bottom: { style: 'thin', color: { argb: 'FFE0E0E0' } }, + left: { style: 'thin', color: { argb: 'FFE0E0E0' } }, + right: { style: 'thin', color: { argb: 'FFE0E0E0' } } + }; + + // Formatação especial para Status + if (header === 'Status') { + const statusValue = rowData[header]; + if (statusValue === 'Aprovado') { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFD4EDDA' } + }; + cell.font = { size: 10, color: { argb: 'FF155724' } }; + } else if (statusValue === 'Reprovado') { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFF8D7DA' } + }; + cell.font = { size: 10, color: { argb: 'FF721C24' } }; + } else if (statusValue === 'Aguardando') { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFFFF3CD' } + }; + cell.font = { size: 10, color: { argb: 'FF856404' } }; + } + } + }); + }); + + // Ajustar largura das colunas + worksheet.columns = [ + { width: 30 }, // Funcionário + { width: 15 }, // Matrícula + { width: 20 }, // Time + { width: 12 }, // Data Início + { width: 12 }, // Data Fim + { width: 8 }, // Dias + { width: 40 }, // Motivo + { width: 15 }, // Status + { width: 15 } // Solicitado em + ]; + + // Congelar linha 2 (cabeçalho da tabela) + worksheet.views = [ + { + state: 'frozen', + ySplit: 2, + topLeftCell: 'A3', + activeCell: 'A3' + } + ]; + + // Gerar arquivo + const nomeArquivo = `relatorio-ausencias-${format(new Date(), 'yyyy-MM-dd-HHmm')}.xlsx`; + const buffer = await workbook.xlsx.writeBuffer(); + 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 = nomeArquivo; + link.click(); + window.URL.revokeObjectURL(url); + + toast.success('Relatório Excel gerado com sucesso!'); + } catch (error) { + console.error('Erro ao gerar Excel:', error); + toast.error('Erro ao gerar relatório Excel. Tente novamente.'); + } finally { + gerandoRelatorio = false; + } + }
@@ -221,8 +636,38 @@
-

Filtros

-
+
+

Filtros

+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
@@ -286,9 +768,10 @@ {#if ausencia.time}
{ausencia.time.nome}
@@ -388,7 +871,7 @@ {#await client.query( api.ausencias.obterDetalhes, { solicitacaoId: solicitacaoSelecionada } ) then detalhes} {#if detalhes} - {/if} - - {#if abaAtiva === 'meu-ponto'} - -
- -
-
-
-
-
- -
-
-

- Registro de Ponto -

-

- Registre sua entrada, saída e intervalos de trabalho -

-
-
-
- -
-
- - - {#if funcionarioIdDisponivel} -
-
- -
-
- {/if} -
- {/if}
@@ -2616,7 +2631,7 @@ {#await client.query( api.ausencias.obterDetalhes, { solicitacaoId: solicitacaoAusenciaAprovar } ) then detalhes} {#if detalhes} -