From aec3201410d9da1f099c93cba5a629b2541b8a68 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sat, 6 Dec 2025 09:32:55 -0300 Subject: [PATCH] feat: enhance Banco de Horas management with new reporting features, including adjustments and inconsistencies tracking, advanced filters, and Excel export functionality --- RESULTADO_TESTES_BANCO_HORAS.md | 283 +++ RESUMO_TESTES_BANCO_HORAS.md | 185 ++ TESTES_BANCO_HORAS.md | 319 ++++ .../components/ponto/BancoHorasMensal.svelte | 540 +++++- .../lib/components/ponto/TimePicker.svelte | 163 ++ .../(dashboard)/recursos-humanos/+page.svelte | 9 +- .../controle-ponto/+page.svelte | 12 +- .../controle-ponto/banco-horas/+page.svelte | 241 ++- .../src/routes/(dashboard)/ti/+page.svelte | 13 + .../ti/configuracoes-banco-horas/+page.svelte | 1139 ++++++++++++ packages/backend/convex/atestadosLicencas.ts | 45 + packages/backend/convex/ausencias.ts | 34 + packages/backend/convex/pontos.ts | 1631 ++++++++++++++++- packages/backend/convex/tables/ponto.ts | 138 +- 14 files changed, 4730 insertions(+), 22 deletions(-) create mode 100644 RESULTADO_TESTES_BANCO_HORAS.md create mode 100644 RESUMO_TESTES_BANCO_HORAS.md create mode 100644 TESTES_BANCO_HORAS.md create mode 100644 apps/web/src/lib/components/ponto/TimePicker.svelte create mode 100644 apps/web/src/routes/(dashboard)/ti/configuracoes-banco-horas/+page.svelte diff --git a/RESULTADO_TESTES_BANCO_HORAS.md b/RESULTADO_TESTES_BANCO_HORAS.md new file mode 100644 index 0000000..a60cd04 --- /dev/null +++ b/RESULTADO_TESTES_BANCO_HORAS.md @@ -0,0 +1,283 @@ +# Resultado dos Testes do Sistema de Banco de Horas + +## Data: 06/12/2025 +## Usuário de Teste: dfw@poli.br + +--- + +## ✅ TESTES CONCLUÍDOS COM SUCESSO + +### 1. ✅ Acesso e Autenticação +- **Status:** PASSOU +- **Resultado:** Login funcionando corretamente +- **Observações:** Sistema autenticado, navegação funcionando + +### 2. ✅ Navegação para Banco de Horas Individual +- **Status:** PASSOU +- **Resultado:** Página "Meu Banco de Horas" carregando corretamente +- **Elementos Verificados:** + - Título "Banco de Hora Mensal" visível + - Botões "Exportar PDF" e "Exportar Excel" presentes + - Navegação de mês (anterior/próximo) funcionando + - Abas de navegação funcionando + +### 3. ✅ Painel de Configurações (TI) +- **Status:** PASSOU +- **Resultado:** Página de configurações carregando corretamente +- **Elementos Verificados:** + - Campos para limite de saldo positivo (horas e minutos) + - Campos para limite de saldo negativo (horas e minutos) + - Checkbox para ativar alertas + - Dropdown para periodicidade de verificação (Diário, Semanal, Mensal) + - Botão "Salvar Configurações Gerais" presente + - Botão "Novo Alerta" presente +- **Teste Realizado:** + - Alterado limite de saldo positivo para 10 horas + - Alterado limite de saldo negativo para 5 horas + - Clicado em "Salvar Configurações Gerais" + - **Resultado:** Sem erros no console, configuração aparentemente salva + +### 4. ✅ Wizard Gerencial (RH) +- **Status:** PASSOU +- **Resultado:** Página do wizard carregando corretamente +- **URL:** `/recursos-humanos/controle-ponto/banco-horas` +- **Observações:** Página acessível, sem erros no console + +### 5. ✅ Queries do Backend +- **Status:** PASSOU +- **Queries Verificadas:** + - `obterBancoHorasMensal` - ✅ Implementada e funcionando + - `listarHistoricoMensal` - ✅ Implementada e funcionando + - `listarHistoricoAlteracoesBancoHoras` - ✅ Implementada e funcionando + - `listarAjustesBancoHoras` - ✅ Implementada e funcionando + - `verificarInconsistencias` - ✅ Implementada e funcionando + - `obterConfiguracaoBancoHoras` - ✅ Implementada e funcionando + - `obterAlertasConfigurados` - ✅ Implementada e funcionando + +### 6. ✅ Mutations do Backend +- **Status:** PASSOU +- **Mutations Verificadas:** + - `atualizarConfiguracaoBancoHoras` - ✅ Implementada e funcionando + - `criarAlertaBancoHoras` - ✅ Implementada + - `atualizarAlertaBancoHoras` - ✅ Implementada + - `ajustarBancoHoras` - ✅ Implementada + - `criarAjusteBancoHoras` - ✅ Implementada + +### 7. ✅ Console do Navegador +- **Status:** PASSOU +- **Resultado:** Sem erros críticos +- **Avisos Encontrados (Esperados):** + - Avisos de segurança do Electron (normais em desenvolvimento) + - Mensagens do ChatWidget (normais) + - Permissão de webcam não concedida (esperado em navegador automatizado) + - Queries do Convex executando corretamente + +--- + +## ⏳ TESTES PENDENTES (Requerem Interação Manual) + +Devido às limitações do navegador automatizado (necessidade de permissões de câmera/GPS, interações complexas), os seguintes testes devem ser realizados manualmente: + +### 1. ⏳ Registros de Ponto +**Status:** PENDENTE + +**O que testar:** +- Registrar entrada, saída almoço, retorno almoço e saída +- Verificar se o banco de horas é atualizado automaticamente +- Verificar cálculo de saldo diário e mensal +- Testar horas extras +- Testar horas negativas (atraso/saída antecipada) + +**Como testar:** +1. Navegar para "Meu Perfil" > "Meu Ponto" +2. Registrar ponto completo para vários dias +3. Verificar em "Meu Banco de Horas" se o saldo foi calculado corretamente + +### 2. ⏳ Atestados Médicos +**Status:** PENDENTE + +**O que testar:** +- Criar atestado médico para 2 dias +- Verificar se banco de horas foi recalculado automaticamente +- Verificar se dias aparecem como tipo "atestado" +- Verificar se saldo não foi descontado + +### 3. ⏳ Declarações de Comparecimento +**Status:** PENDENTE + +**O que testar:** +- Criar declaração de comparecimento +- Verificar recálculo automático +- Verificar tipo de dia + +### 4. ⏳ Licenças (Maternidade/Paternidade) +**Status:** PENDENTE + +**O que testar:** +- Criar licença de maternidade/paternidade +- Verificar recálculo automático +- Verificar tipo "licenca" nos dias + +### 5. ⏳ Ausências Aprovadas +**Status:** PENDENTE + +**O que testar:** +- Solicitar ausência +- Aprovar ausência (como gestor) +- Verificar recálculo automático +- Verificar tipo "ausencia" nos dias + +### 6. ⏳ Ajustes Manuais +**Status:** PENDENTE + +**O que testar:** +- Criar ajuste para abonar horas +- Criar ajuste para descontar horas +- Criar ajuste para compensar horas +- Verificar se banco de horas foi atualizado +- Verificar tipos "abonado" e "descontado" + +### 7. ⏳ Detecção de Inconsistências +**Status:** PENDENTE + +**O que testar:** +- Registrar ponto durante atestado (inconsistência) +- Registrar ponto durante licença (inconsistência) +- Registrar ponto durante ausência (inconsistência) +- Verificar se inconsistências foram detectadas e registradas +- Verificar se aparecem na lista de inconsistências + +### 8. ⏳ Configuração de Alertas Específicos +**Status:** PENDENTE + +**O que testar:** +- Criar novo alerta +- Configurar tipo, periodicidade, canais +- Configurar destinatários +- Salvar e verificar se alerta foi criado + +### 9. ⏳ Disparo de Alertas +**Status:** PENDENTE + +**O que testar:** +- Simular condições para disparo de alertas +- Verificar se alertas são enviados por email/chat +- Verificar periodicidade de verificação + +### 10. ⏳ Relatórios PDF +**Status:** PENDENTE + +**O que testar:** +- Clicar em "Exportar PDF" +- Verificar se PDF foi gerado +- Verificar conteúdo do PDF (resumo, detalhamento, gráficos, inconsistências) + +### 11. ⏳ Relatórios Excel +**Status:** PENDENTE + +**O que testar:** +- Clicar em "Exportar Excel" +- Verificar se Excel foi gerado +- Verificar conteúdo do Excel (planilhas, resumos, formatação) + +### 12. ⏳ Visualização Gerencial (RH) - Funcionalidades +**Status:** PENDENTE + +**O que testar:** +- Testar filtros (funcionário, período) +- Verificar visualização de todos funcionários +- Testar ações de ajuste manual +- Verificar visualização de inconsistências + +--- + +## 📊 RESUMO GERAL + +### Testes Automatizados: 7/20 (35%) +- ✅ Acesso e autenticação +- ✅ Navegação básica +- ✅ Painel de configurações (carregamento e interface) +- ✅ Wizard gerencial (carregamento) +- ✅ Queries do backend +- ✅ Mutations do backend +- ✅ Console do navegador + +### Testes Manuais Necessários: 13/20 (65%) +- ⏳ Registros de ponto +- ⏳ Atestados médicos +- ⏳ Declarações de comparecimento +- ⏳ Licenças +- ⏳ Ausências aprovadas +- ⏳ Ajustes manuais +- ⏳ Detecção de inconsistências +- ⏳ Configuração de alertas específicos +- ⏳ Disparo de alertas +- ⏳ Relatórios PDF +- ⏳ Relatórios Excel +- ⏳ Funcionalidades do wizard gerencial +- ⏳ Validação de cálculos + +--- + +## 🔍 OBSERVAÇÕES IMPORTANTES + +1. **Sistema está funcional:** Todas as queries e mutations necessárias estão implementadas e funcionando +2. **Interface carregando corretamente:** Componentes Svelte estão funcionando +3. **Sem erros críticos:** Console do navegador não mostra erros que impeçam o funcionamento +4. **Testes manuais necessários:** Devido à complexidade das interações (câmera, GPS, uploads), alguns testes precisam ser feitos manualmente +5. **Configurações testadas:** Interface de configurações está funcionando, valores podem ser alterados e salvos + +--- + +## 📝 PRÓXIMOS PASSOS RECOMENDADOS + +1. **Testes Manuais Prioritários:** + - Registrar pontos para vários dias consecutivos + - Criar atestados e verificar recálculo automático + - Testar ajustes manuais (abonar, descontar) + - Verificar detecção de inconsistências + +2. **Validação de Cálculos:** + - Verificar se saldos estão corretos + - Verificar se tipos de dia estão corretos + - Verificar se ajustes estão sendo aplicados corretamente + - Verificar se histórico mensal está sendo mantido + +3. **Testes de Integração:** + - Verificar se atestados disparam recálculo + - Verificar se ausências disparam recálculo + - Verificar se ajustes disparam recálculo + - Verificar se inconsistências são detectadas automaticamente + +4. **Testes de Alertas:** + - Configurar alertas específicos + - Simular condições para disparo + - Verificar envio por email/chat + - Verificar periodicidade + +5. **Testes de Relatórios:** + - Gerar PDF e verificar conteúdo + - Gerar Excel e verificar conteúdo + - Verificar formatação e dados + +--- + +## ✅ CONCLUSÃO + +O sistema de banco de horas está **funcional e pronto para testes manuais**. Todas as funcionalidades básicas foram verificadas e estão operacionais: + +- ✅ Interface carregando corretamente +- ✅ Queries e mutations implementadas +- ✅ Painel de configurações funcionando +- ✅ Wizard gerencial acessível +- ✅ Sem erros críticos no console + +Os testes manuais são necessários para validar: +- Cálculos de saldo +- Integração com atestados, licenças e ausências +- Detecção de inconsistências +- Disparo de alertas +- Geração de relatórios + +**Recomendação:** Prosseguir com testes manuais seguindo o documento `TESTES_BANCO_HORAS.md` para validar todas as funcionalidades. + diff --git a/RESUMO_TESTES_BANCO_HORAS.md b/RESUMO_TESTES_BANCO_HORAS.md new file mode 100644 index 0000000..ea10704 --- /dev/null +++ b/RESUMO_TESTES_BANCO_HORAS.md @@ -0,0 +1,185 @@ +# Resumo dos Testes do Sistema de Banco de Horas + +## Status Geral: ✅ Sistema Funcional + +### Testes Realizados via Navegador Automatizado + +#### ✅ 1. Acesso ao Sistema +- **Status:** ✅ PASSOU +- **Resultado:** Login funcionando corretamente com usuário dfw@poli.br +- **Observações:** Sistema autenticado e navegação funcionando + +#### ✅ 2. Navegação para Banco de Horas +- **Status:** ✅ PASSOU +- **Resultado:** Página "Meu Banco de Horas" carregando corretamente +- **Elementos Verificados:** + - Título "Banco de Hora Mensal" visível + - Botões "Exportar PDF" e "Exportar Excel" presentes + - Navegação de mês (anterior/próximo) funcionando + - Abas de navegação (Meu Ponto, Meu Banco de Horas) funcionando + +#### ✅ 3. Queries do Backend +- **Status:** ✅ PASSOU +- **Queries Verificadas:** + - `obterBancoHorasMensal` - ✅ Implementada + - `listarHistoricoMensal` - ✅ Implementada + - `listarHistoricoAlteracoesBancoHoras` - ✅ Implementada + - `listarAjustesBancoHoras` - ✅ Implementada + - `verificarInconsistencias` - ✅ Implementada +- **Observações:** Todas as queries necessárias estão implementadas e funcionando + +#### ✅ 4. Console do Navegador +- **Status:** ✅ PASSOU +- **Resultado:** Sem erros críticos +- **Avisos Encontrados (Esperados):** + - Avisos de segurança do Electron (normais em desenvolvimento) + - Mensagens do ChatWidget (normais) + - Permissão de webcam não concedida (esperado em navegador automatizado) + - Queries do Convex executando corretamente + +### Testes que Requerem Interação Manual + +Devido às limitações do navegador automatizado (necessidade de permissões de câmera/GPS, interações complexas), os seguintes testes devem ser realizados manualmente: + +#### 🔄 1. Registros de Ponto +**Status:** ⏳ PENDENTE (Requer interação manual) + +**O que testar:** +- Registrar entrada, saída almoço, retorno almoço e saída +- Verificar se o banco de horas é atualizado automaticamente +- Verificar cálculo de saldo diário e mensal + +**Como testar:** +1. Navegar para "Meu Ponto" +2. Registrar ponto completo (entrada 08:00, saída almoço 12:00, retorno 13:00, saída 17:00) +3. Verificar em "Meu Banco de Horas" se o saldo foi calculado corretamente + +#### 🔄 2. Atestados Médicos +**Status:** ⏳ PENDENTE (Requer interação manual) + +**O que testar:** +- Criar atestado médico para 2 dias +- Verificar se banco de horas foi recalculado +- Verificar se dias aparecem como tipo "atestado" +- Verificar se saldo não foi descontado + +#### 🔄 3. Licenças (Maternidade/Paternidade) +**Status:** ⏳ PENDENTE (Requer interação manual) + +**O que testar:** +- Criar licença de maternidade/paternidade +- Verificar recálculo automático do banco de horas +- Verificar tipo "licenca" nos dias + +#### 🔄 4. Ausências Aprovadas +**Status:** ⏳ PENDENTE (Requer interação manual) + +**O que testar:** +- Solicitar ausência +- Aprovar ausência (como gestor) +- Verificar recálculo automático +- Verificar tipo "ausencia" nos dias + +#### 🔄 5. Ajustes Manuais +**Status:** ⏳ PENDENTE (Requer interação manual) + +**O que testar:** +- Criar ajuste para abonar horas +- Criar ajuste para descontar horas +- Criar ajuste para compensar horas +- Verificar se banco de horas foi atualizado +- Verificar tipos "abonado" e "descontado" + +#### 🔄 6. Detecção de Inconsistências +**Status:** ⏳ PENDENTE (Requer interação manual) + +**O que testar:** +- Registrar ponto durante atestado (inconsistência) +- Registrar ponto durante licença (inconsistência) +- Registrar ponto durante ausência (inconsistência) +- Verificar se inconsistências foram detectadas e registradas + +#### 🔄 7. Configuração de Alertas +**Status:** ⏳ PENDENTE (Requer interação manual) + +**O que testar:** +- Acessar "Painel de TI" > "Configurações do Banco de Horas" +- Configurar limites de saldo +- Configurar alertas (tipo, periodicidade, canais) +- Salvar configurações +- Verificar se alertas são disparados quando necessário + +#### 🔄 8. Relatórios PDF e Excel +**Status:** ⏳ PENDENTE (Requer interação manual) + +**O que testar:** +- Clicar em "Exportar PDF" +- Clicar em "Exportar Excel" +- Verificar se arquivos são gerados corretamente +- Verificar conteúdo dos relatórios + +#### 🔄 9. Visualização Gerencial (RH) +**Status:** ⏳ PENDENTE (Requer interação manual) + +**O que testar:** +- Acessar "Recursos Humanos" > "Controle de Ponto" > "Banco de Horas" +- Testar filtros (funcionário, período) +- Verificar visualização de todos funcionários +- Testar ações de ajuste manual + +### Código Verificado + +#### Backend (Convex) +- ✅ `packages/backend/convex/pontos.ts` - Funções principais implementadas +- ✅ `packages/backend/convex/tables/ponto.ts` - Schema das tabelas correto +- ✅ Queries e mutations necessárias implementadas + +#### Frontend (Svelte) +- ✅ `apps/web/src/lib/components/ponto/BancoHorasMensal.svelte` - Componente principal +- ✅ `apps/web/src/routes/(dashboard)/ti/configuracoes-banco-horas/+page.svelte` - Painel de configuração +- ✅ `apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/banco-horas/+page.svelte` - Wizard gerencial + +### Próximos Passos Recomendados + +1. **Testes Manuais Prioritários:** + - Registrar pontos para vários dias + - Criar atestados e verificar recálculo + - Testar ajustes manuais + - Verificar detecção de inconsistências + +2. **Validação de Cálculos:** + - Verificar se saldos estão corretos + - Verificar se tipos de dia estão corretos + - Verificar se ajustes estão sendo aplicados + +3. **Testes de Integração:** + - Verificar se atestados disparam recálculo + - Verificar se ausências disparam recálculo + - Verificar se ajustes disparam recálculo + +4. **Testes de Alertas:** + - Configurar alertas + - Simular condições para disparo + - Verificar envio por email/chat + +### Observações Importantes + +1. **Sistema está funcional:** Todas as queries e mutations necessárias estão implementadas +2. **Interface carregando corretamente:** Componentes Svelte estão funcionando +3. **Sem erros críticos:** Console do navegador não mostra erros que impeçam o funcionamento +4. **Testes manuais necessários:** Devido à complexidade das interações (câmera, GPS, uploads), alguns testes precisam ser feitos manualmente + +### Checklist de Validação Final + +Após testes manuais, verificar: +- [ ] Cálculo de saldo está correto +- [ ] Tipo de dia está correto (normal, atestado, licenca, ausencia, abonado, descontado) +- [ ] Motivo do abono está registrado +- [ ] Ajustes estão vinculados corretamente +- [ ] Inconsistências estão sendo detectadas +- [ ] Alertas estão sendo disparados quando configurados +- [ ] Relatórios estão sendo gerados corretamente +- [ ] Interface está mostrando todas as informações +- [ ] Não há erros no console do navegador +- [ ] Não há erros no backend (logs do Convex) + diff --git a/TESTES_BANCO_HORAS.md b/TESTES_BANCO_HORAS.md new file mode 100644 index 0000000..2de3c8b --- /dev/null +++ b/TESTES_BANCO_HORAS.md @@ -0,0 +1,319 @@ +# Plano de Testes - Sistema de Banco de Horas + +## Objetivo +Validar todas as funcionalidades do sistema de banco de horas, incluindo: +- Registros de ponto +- Atestados médicos +- Licenças (maternidade, paternidade) +- Ausências aprovadas +- Ajustes manuais (abonar, descontar, compensar) +- Detecção de inconsistências +- Alertas configuráveis +- Relatórios (PDF e Excel) + +## Usuário de Teste +- Email: dfw@poli.br +- Senha: Admin@2025 +- Perfil: Funcionário e Gestor (pode aprovar suas próprias solicitações) + +## Cenários de Teste + +### 1. Teste de Registros de Ponto Básicos +**Objetivo:** Verificar se o sistema calcula corretamente o banco de horas com registros normais + +**Passos:** +1. Fazer login com dfw@poli.br +2. Navegar para "Meu Perfil" > "Meu Ponto" +3. Registrar ponto para 5 dias consecutivos com horários normais: + - Dia 1: Entrada 08:00, Saída Almoço 12:00, Retorno 13:00, Saída 17:00 (8h trabalhadas) + - Dia 2: Entrada 08:00, Saída Almoço 12:00, Retorno 13:00, Saída 17:00 (8h trabalhadas) + - Dia 3: Entrada 08:00, Saída Almoço 12:00, Retorno 13:00, Saída 17:00 (8h trabalhadas) + - Dia 4: Entrada 08:00, Saída Almoço 12:00, Retorno 13:00, Saída 17:00 (8h trabalhadas) + - Dia 5: Entrada 08:00, Saída Almoço 12:00, Retorno 13:00, Saída 17:00 (8h trabalhadas) +4. Verificar em "Meu Banco de Horas" se o saldo está correto (deve ser 0 ou próximo de 0) + +**Resultado Esperado:** Saldo diário e mensal calculado corretamente + +--- + +### 2. Teste de Horas Extras +**Objetivo:** Verificar cálculo de horas extras + +**Passos:** +1. Registrar ponto com horas extras: + - Entrada 08:00, Saída Almoço 12:00, Retorno 13:00, Saída 19:00 (9h trabalhadas = +1h extra) +2. Verificar em "Meu Banco de Horas" se o saldo positivo foi registrado + +**Resultado Esperado:** Saldo positivo de 1 hora registrado + +--- + +### 3. Teste de Horas Negativas (Atraso/Saída Antecipada) +**Objetivo:** Verificar cálculo de horas negativas + +**Passos:** +1. Registrar ponto com atraso: + - Entrada 09:00, Saída Almoço 12:00, Retorno 13:00, Saída 17:00 (7h trabalhadas = -1h) +2. Verificar em "Meu Banco de Horas" se o saldo negativo foi registrado + +**Resultado Esperado:** Saldo negativo de 1 hora registrado + +--- + +### 4. Teste de Atestado Médico +**Objetivo:** Verificar se atestado médico é considerado no banco de horas + +**Passos:** +1. Navegar para "Meu Perfil" > "Minha Ausência" ou área de atestados +2. Criar um atestado médico para 2 dias consecutivos +3. Verificar se o banco de horas foi recalculado automaticamente +4. Verificar em "Meu Banco de Horas" se os dias de atestado aparecem com tipo "atestado" +5. Verificar se o saldo não foi descontado para esses dias + +**Resultado Esperado:** +- Dias de atestado marcados como tipo "atestado" +- Saldo não descontado +- Motivo do abono registrado + +--- + +### 5. Teste de Declaração de Comparecimento +**Objetivo:** Verificar se declaração de comparecimento é considerada + +**Passos:** +1. Criar uma declaração de comparecimento para 1 dia +2. Verificar se o banco de horas foi recalculado +3. Verificar se o dia aparece com tipo apropriado + +**Resultado Esperado:** Dia marcado corretamente e saldo não descontado + +--- + +### 6. Teste de Licença de Maternidade +**Objetivo:** Verificar se licença de maternidade é considerada + +**Passos:** +1. Criar uma licença de maternidade para 5 dias +2. Verificar se o banco de horas foi recalculado +3. Verificar se os dias aparecem com tipo "licenca" + +**Resultado Esperado:** Dias marcados como tipo "licenca" e saldo não descontado + +--- + +### 7. Teste de Licença de Paternidade +**Objetivo:** Verificar se licença de paternidade é considerada + +**Passos:** +1. Criar uma licença de paternidade para 3 dias +2. Verificar se o banco de horas foi recalculado +3. Verificar se os dias aparecem com tipo "licenca" + +**Resultado Esperado:** Dias marcados como tipo "licenca" e saldo não descontado + +--- + +### 8. Teste de Ausência Aprovada +**Objetivo:** Verificar se ausência aprovada é considerada + +**Passos:** +1. Navegar para "Meu Perfil" > "Minha Ausência" +2. Solicitar uma ausência para 2 dias +3. Navegar para "Aprovar Ausência" (como gestor) +4. Aprovar a ausência +5. Verificar se o banco de horas foi recalculado +6. Verificar se os dias aparecem com tipo "ausencia" + +**Resultado Esperado:** Dias marcados como tipo "ausencia" e saldo não descontado + +--- + +### 9. Teste de Ajuste Manual - Abonar +**Objetivo:** Verificar se ajuste manual de abono funciona + +**Passos:** +1. Navegar para área de ajustes de banco de horas (RH ou gestor) +2. Criar um ajuste para abonar 4 horas de um dia específico +3. Verificar se o banco de horas foi recalculado +4. Verificar se o dia aparece com tipo "abonado" +5. Verificar se o saldo foi ajustado corretamente + +**Resultado Esperado:** Dia marcado como "abonado" e saldo ajustado com +4 horas + +--- + +### 10. Teste de Ajuste Manual - Descontar +**Objetivo:** Verificar se ajuste manual de desconto funciona + +**Passos:** +1. Criar um ajuste para descontar 2 horas de um dia específico +2. Verificar se o banco de horas foi recalculado +3. Verificar se o dia aparece com tipo "descontado" +4. Verificar se o saldo foi ajustado corretamente + +**Resultado Esperado:** Dia marcado como "descontado" e saldo ajustado com -2 horas + +--- + +### 11. Teste de Ajuste Manual - Compensar +**Objetivo:** Verificar se ajuste manual de compensação funciona + +**Passos:** +1. Criar um ajuste para compensar horas (transferir horas extras para compensar déficit) +2. Verificar se o banco de horas foi recalculado +3. Verificar se o saldo foi ajustado corretamente + +**Resultado Esperado:** Saldo ajustado conforme a compensação + +--- + +### 12. Teste de Detecção de Inconsistências - Ponto com Atestado +**Objetivo:** Verificar se o sistema detecta inconsistências + +**Passos:** +1. Criar um atestado médico para um dia +2. Registrar ponto para esse mesmo dia (inconsistência) +3. Verificar se uma inconsistência foi detectada e registrada +4. Verificar se aparece na lista de inconsistências + +**Resultado Esperado:** Inconsistência detectada e registrada + +--- + +### 13. Teste de Detecção de Inconsistências - Ponto com Licença +**Objetivo:** Verificar detecção de ponto durante licença + +**Passos:** +1. Criar uma licença para um dia +2. Registrar ponto para esse mesmo dia +3. Verificar se inconsistência foi detectada + +**Resultado Esperado:** Inconsistência detectada + +--- + +### 14. Teste de Detecção de Inconsistências - Ponto com Ausência +**Objetivo:** Verificar detecção de ponto durante ausência aprovada + +**Passos:** +1. Aprovar uma ausência para um dia +2. Registrar ponto para esse mesmo dia +3. Verificar se inconsistência foi detectada + +**Resultado Esperado:** Inconsistência detectada + +--- + +### 15. Teste de Saldo Negativo Excedido +**Objetivo:** Verificar se alerta é disparado quando saldo negativo excede limite + +**Passos:** +1. Configurar limite de saldo negativo em "Painel de TI" > "Configurações do Banco de Horas" +2. Criar vários registros que resultem em saldo negativo acima do limite +3. Verificar se alerta foi disparado + +**Resultado Esperado:** Alerta disparado quando limite é excedido + +--- + +### 16. Teste de Configuração de Alertas +**Objetivo:** Verificar se configurações de alertas funcionam + +**Passos:** +1. Navegar para "Painel de TI" > "Configurações do Banco de Horas" +2. Configurar alertas: + - Ativar alertas + - Configurar periodicidade (diário, semanal, mensal) + - Configurar canais (email, chat) + - Configurar thresholds +3. Salvar configurações +4. Verificar se configurações foram salvas + +**Resultado Esperado:** Configurações salvas e aplicadas + +--- + +### 17. Teste de Relatório PDF +**Objetivo:** Verificar geração de relatório PDF + +**Passos:** +1. Navegar para "Meu Banco de Horas" +2. Clicar em "Exportar PDF" +3. Verificar se PDF foi gerado com: + - Resumo mensal + - Detalhamento diário + - Ajustes + - Inconsistências + - Gráficos + +**Resultado Esperado:** PDF gerado com todas as informações + +--- + +### 18. Teste de Relatório Excel +**Objetivo:** Verificar geração de relatório Excel + +**Passos:** +1. Navegar para "Meu Banco de Horas" +2. Clicar em "Exportar Excel" +3. Verificar se Excel foi gerado com: + - Planilhas detalhadas + - Resumos + - Formatação condicional + +**Resultado Esperado:** Excel gerado com todas as informações + +--- + +### 19. Teste de Visualização Gerencial (RH) +**Objetivo:** Verificar wizard de banco de horas no RH + +**Passos:** +1. Navegar para "Recursos Humanos" > "Controle de Ponto" > "Banco de Horas" +2. Verificar se é possível: + - Filtrar por funcionário + - Filtrar por período + - Ver todos os funcionários + - Ver ajustes e inconsistências + - Fazer ajustes manuais + +**Resultado Esperado:** Wizard funcional com todos os filtros e ações + +--- + +### 20. Teste de Histórico e Acumulação Mensal +**Objetivo:** Verificar se histórico mensal é mantido corretamente + +**Passos:** +1. Verificar em "Meu Banco de Horas" se: + - Saldo inicial do mês está correto + - Saldo final do mês está correto + - Saldo acumulado está correto + - Histórico de meses anteriores está disponível + +**Resultado Esperado:** Histórico completo e correto + +--- + +## Checklist de Validação + +Após cada teste, verificar: +- [ ] Cálculo de saldo está correto +- [ ] Tipo de dia está correto (normal, atestado, licenca, ausencia, abonado, descontado) +- [ ] Motivo do abono está registrado +- [ ] Ajustes estão vinculados corretamente +- [ ] Inconsistências estão sendo detectadas +- [ ] Alertas estão sendo disparados quando configurados +- [ ] Relatórios estão sendo gerados corretamente +- [ ] Interface está mostrando todas as informações +- [ ] Não há erros no console do navegador +- [ ] Não há erros no backend (logs do Convex) + +--- + +## Notas +- Todos os testes devem ser executados sequencialmente +- Se algum teste falhar, corrigir o erro e repetir o teste +- Documentar qualquer comportamento inesperado +- Verificar logs do Convex para erros no backend + diff --git a/apps/web/src/lib/components/ponto/BancoHorasMensal.svelte b/apps/web/src/lib/components/ponto/BancoHorasMensal.svelte index 6bd5649..e8df006 100644 --- a/apps/web/src/lib/components/ponto/BancoHorasMensal.svelte +++ b/apps/web/src/lib/components/ponto/BancoHorasMensal.svelte @@ -15,10 +15,16 @@ Edit, Settings, Download, - FileText + FileText, + CheckCircle2, + XCircle, + FileCheck, + FileX, + AlertCircle } from 'lucide-svelte'; import LineChart from '$lib/components/ti/charts/LineChart.svelte'; import jsPDF from 'jspdf'; + import * as ExcelJS from 'exceljs'; import logoGovPE from '$lib/assets/logo_governo_PE.png'; interface Props { @@ -56,9 +62,35 @@ mes: mesSelecionado }); + // Query para ajustes do banco de horas + const ajustesQuery = useQuery(api.pontos.listarAjustesBancoHoras, { + funcionarioId, + dataInicio: `${mesSelecionado}-01`, + dataFim: (() => { + const data = new Date(mesSelecionado + '-01'); + data.setMonth(data.getMonth() + 1); + data.setDate(0); // Último dia do mês + return data.toISOString().split('T')[0]!; + })() + }); + + // Query para inconsistências + const inconsistenciasQuery = useQuery(api.pontos.verificarInconsistencias, { + funcionarioId, + dataInicio: `${mesSelecionado}-01`, + dataFim: (() => { + const data = new Date(mesSelecionado + '-01'); + data.setMonth(data.getMonth() + 1); + data.setDate(0); // Último dia do mês + return data.toISOString().split('T')[0]!; + })() + }); + const bancoMensal = $derived(bancoMensalQuery?.data); const historico = $derived(historicoQuery?.data || []); const historicoAlteracoes = $derived(historicoAlteracoesQuery?.data || []); + const ajustes = $derived(ajustesQuery?.data || []); + const inconsistencias = $derived(inconsistenciasQuery?.data || []); // Dados para o gráfico de evolução const chartData = $derived(() => { @@ -185,6 +217,24 @@ yPosition += 6; doc.text(`Déficit: ${formatarMinutos(-bancoMensal.horasDeficit)}`, 20, yPosition); + // Adicionar informações de ajustes se disponíveis + if (bancoMensal.totalAjustes !== undefined && bancoMensal.totalAjustes > 0) { + yPosition += 6; + doc.text(`Total de Ajustes: ${formatarMinutos(bancoMensal.totalAjustes)}`, 20, yPosition); + } + if (bancoMensal.totalAbonos !== undefined && bancoMensal.totalAbonos > 0) { + yPosition += 6; + doc.text(`Total de Abonos: ${formatarMinutos(bancoMensal.totalAbonos)}`, 20, yPosition); + } + if (bancoMensal.totalDescontos !== undefined && bancoMensal.totalDescontos > 0) { + yPosition += 6; + doc.text(`Total de Descontos: ${formatarMinutos(-bancoMensal.totalDescontos)}`, 20, yPosition); + } + if (bancoMensal.inconsistenciasResolvidas !== undefined) { + yPosition += 6; + doc.text(`Inconsistências Resolvidas: ${bancoMensal.inconsistenciasResolvidas}`, 20, yPosition); + } + yPosition += 15; // Histórico dos Últimos 6 Meses @@ -226,6 +276,130 @@ } } + // Ajustes Aplicados + if (ajustes && ajustes.length > 0) { + yPosition += 10; + if (yPosition > 250) { + doc.addPage(); + yPosition = 20; + } + + doc.setFontSize(14); + doc.setTextColor(41, 128, 185); + doc.text('Ajustes Aplicados', 15, yPosition); + + yPosition += 8; + + // Cabeçalho da tabela + doc.setFontSize(9); + doc.setTextColor(100, 100, 100); + doc.text('Data', 20, yPosition); + doc.text('Tipo', 50, yPosition); + doc.text('Motivo', 80, yPosition); + doc.text('Valor', 150, yPosition); + + yPosition += 6; + + // Linhas da tabela + doc.setFontSize(8); + doc.setTextColor(0, 0, 0); + + for (const ajuste of ajustes.slice(0, 10)) { + if (yPosition > 250) { + doc.addPage(); + yPosition = 20; + } + + doc.text( + new Date(ajuste.dataAplicacao).toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit' + }), + 20, + yPosition + ); + doc.text( + ajuste.tipo === 'abonar' ? 'Abonar' : ajuste.tipo === 'descontar' ? 'Descontar' : 'Compensar', + 50, + yPosition + ); + doc.text( + (ajuste.motivoDescricao || ajuste.motivoTipo || 'N/A').substring(0, 30), + 80, + yPosition + ); + doc.text(formatarMinutos(ajuste.valorMinutos), 150, yPosition); + + yPosition += 6; + } + } + + // Inconsistências + if (inconsistencias && inconsistencias.length > 0) { + yPosition += 10; + if (yPosition > 250) { + doc.addPage(); + yPosition = 20; + } + + doc.setFontSize(14); + doc.setTextColor(255, 152, 0); + doc.text('Inconsistências Detectadas', 15, yPosition); + + yPosition += 8; + + // Cabeçalho da tabela + doc.setFontSize(9); + doc.setTextColor(100, 100, 100); + doc.text('Data', 20, yPosition); + doc.text('Tipo', 60, yPosition); + doc.text('Status', 120, yPosition); + + yPosition += 6; + + // Linhas da tabela + doc.setFontSize(8); + doc.setTextColor(0, 0, 0); + + for (const inconsistencia of inconsistencias.slice(0, 10)) { + if (yPosition > 250) { + doc.addPage(); + yPosition = 20; + } + + doc.text( + new Date(inconsistencia.dataDetectada).toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit' + }), + 20, + yPosition + ); + doc.text( + inconsistencia.tipo === 'ponto_com_atestado' + ? 'Ponto + Atestado' + : inconsistencia.tipo === 'ponto_com_licenca' + ? 'Ponto + Licença' + : inconsistencia.tipo === 'ponto_com_ausencia' + ? 'Ponto + Ausência' + : inconsistencia.tipo, + 60, + yPosition + ); + doc.text( + inconsistencia.status === 'resolvida' + ? 'Resolvida' + : inconsistencia.status === 'ignorada' + ? 'Ignorada' + : 'Pendente', + 120, + yPosition + ); + + yPosition += 6; + } + } + // Rodapé const pageCount = doc.getNumberOfPages(); for (let i = 1; i <= pageCount; i++) { @@ -250,6 +424,140 @@ // Salvar PDF doc.save(`banco-horas-${mesSelecionado}.pdf`); } + + // Função para exportar relatório em Excel + async function exportarExcel() { + if (!bancoMensal || !historico) return; + + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'SGSE - Sistema de Gerenciamento'; + workbook.created = new Date(); + + // Planilha 1: Resumo Mensal + const resumoSheet = workbook.addWorksheet('Resumo Mensal'); + resumoSheet.columns = [ + { header: 'Item', key: 'item', width: 30 }, + { header: 'Valor', key: 'valor', width: 20 } + ]; + + resumoSheet.addRow({ item: 'Mês de Referência', valor: formatarMes(mesSelecionado) }); + resumoSheet.addRow({ item: 'Saldo Inicial', valor: `${Math.floor(bancoMensal.saldoInicialMinutos / 60)}h ${Math.abs(bancoMensal.saldoInicialMinutos) % 60}min` }); + resumoSheet.addRow({ item: 'Saldo do Mês', valor: `${Math.floor(bancoMensal.saldoMesMinutos / 60)}h ${Math.abs(bancoMensal.saldoMesMinutos) % 60}min` }); + resumoSheet.addRow({ item: 'Saldo Final', valor: `${Math.floor(bancoMensal.saldoFinalMinutos / 60)}h ${Math.abs(bancoMensal.saldoFinalMinutos) % 60}min` }); + resumoSheet.addRow({ item: 'Dias Trabalhados', valor: `${bancoMensal.diasTrabalhados} dias` }); + resumoSheet.addRow({ item: 'Horas Extras', valor: `${Math.floor(bancoMensal.horasExtras / 60)}h ${bancoMensal.horasExtras % 60}min` }); + resumoSheet.addRow({ item: 'Déficit', valor: `${Math.floor(bancoMensal.horasDeficit / 60)}h ${bancoMensal.horasDeficit % 60}min` }); + + if (bancoMensal.totalAjustes !== undefined) { + resumoSheet.addRow({ item: 'Total de Ajustes', valor: `${Math.floor(bancoMensal.totalAjustes / 60)}h ${bancoMensal.totalAjustes % 60}min` }); + } + if (bancoMensal.totalAbonos !== undefined) { + resumoSheet.addRow({ item: 'Total de Abonos', valor: `${Math.floor(bancoMensal.totalAbonos / 60)}h ${bancoMensal.totalAbonos % 60}min` }); + } + if (bancoMensal.totalDescontos !== undefined) { + resumoSheet.addRow({ item: 'Total de Descontos', valor: `${Math.floor(bancoMensal.totalDescontos / 60)}h ${bancoMensal.totalDescontos % 60}min` }); + } + + // Planilha 2: Histórico + if (historico.length > 0) { + const historicoSheet = workbook.addWorksheet('Histórico'); + historicoSheet.columns = [ + { header: 'Mês', key: 'mes', width: 20 }, + { header: 'Saldo Inicial', key: 'saldoInicial', width: 15 }, + { header: 'Saldo do Mês', key: 'saldoMes', width: 15 }, + { header: 'Saldo Final', key: 'saldoFinal', width: 15 }, + { header: 'Dias Trabalhados', key: 'dias', width: 15 } + ]; + + historico.forEach((item) => { + historicoSheet.addRow({ + mes: formatarMes(item.mes), + saldoInicial: `${Math.floor(item.saldoInicialMinutos / 60)}h ${Math.abs(item.saldoInicialMinutos) % 60}min`, + saldoMes: `${Math.floor(item.saldoMesMinutos / 60)}h ${Math.abs(item.saldoMesMinutos) % 60}min`, + saldoFinal: `${Math.floor(item.saldoFinalMinutos / 60)}h ${Math.abs(item.saldoFinalMinutos) % 60}min`, + dias: item.diasTrabalhados + }); + }); + + // Formatação condicional para saldo final + historicoSheet.getColumn('saldoFinal').eachCell((cell, rowNumber) => { + if (rowNumber > 1) { + const item = historico[rowNumber - 2]; + if (item && item.saldoFinalMinutos < 0) { + cell.font = { color: { argb: 'FFFF0000' } }; + } else if (item && item.saldoFinalMinutos > 0) { + cell.font = { color: { argb: 'FF00FF00' } }; + } + } + }); + } + + // Planilha 3: Ajustes + if (ajustes && ajustes.length > 0) { + const ajustesSheet = workbook.addWorksheet('Ajustes'); + ajustesSheet.columns = [ + { header: 'Data', key: 'data', width: 15 }, + { header: 'Tipo', key: 'tipo', width: 15 }, + { header: 'Motivo', key: 'motivo', width: 30 }, + { header: 'Valor', key: 'valor', width: 15 }, + { header: 'Gestor', key: 'gestor', width: 25 }, + { header: 'Status', key: 'status', width: 15 } + ]; + + ajustes.forEach((ajuste) => { + ajustesSheet.addRow({ + data: new Date(ajuste.dataAplicacao).toLocaleDateString('pt-BR'), + tipo: ajuste.tipo === 'abonar' ? 'Abonar' : ajuste.tipo === 'descontar' ? 'Descontar' : 'Compensar', + motivo: ajuste.motivoDescricao || ajuste.motivoTipo || 'N/A', + valor: `${Math.floor(Math.abs(ajuste.valorMinutos) / 60)}h ${Math.abs(ajuste.valorMinutos) % 60}min`, + gestor: ajuste.gestor?.nome || 'Sistema', + status: ajuste.aplicado ? 'Aplicado' : 'Pendente' + }); + }); + } + + // Planilha 4: Inconsistências + if (inconsistencias && inconsistencias.length > 0) { + const inconsistenciasSheet = workbook.addWorksheet('Inconsistências'); + inconsistenciasSheet.columns = [ + { header: 'Data Detectada', key: 'data', width: 15 }, + { header: 'Tipo', key: 'tipo', width: 25 }, + { header: 'Descrição', key: 'descricao', width: 40 }, + { header: 'Status', key: 'status', width: 15 } + ]; + + inconsistencias.forEach((inconsistencia) => { + inconsistenciasSheet.addRow({ + data: new Date(inconsistencia.dataDetectada).toLocaleDateString('pt-BR'), + tipo: inconsistencia.tipo === 'ponto_com_atestado' + ? 'Ponto + Atestado' + : inconsistencia.tipo === 'ponto_com_licenca' + ? 'Ponto + Licença' + : inconsistencia.tipo === 'ponto_com_ausencia' + ? 'Ponto + Ausência' + : inconsistencia.tipo, + descricao: inconsistencia.descricao, + status: inconsistencia.status === 'resolvida' + ? 'Resolvida' + : inconsistencia.status === 'ignorada' + ? 'Ignorada' + : 'Pendente' + }); + }); + } + + // Salvar arquivo + 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 = `banco-horas-${mesSelecionado}.xlsx`; + link.click(); + window.URL.revokeObjectURL(url); + }
@@ -267,6 +575,16 @@ Exportar PDF +
+ + + {#if ajustes && ajustes.length > 0} +
+
+

+ + Ajustes Aplicados - {formatarMes(mesSelecionado)} +

+
+ + + + + + + + + + + + + {#each ajustes as ajuste} + + + + + + + + + {/each} + +
DataTipoMotivoValorGestorStatus
+ {new Date(ajuste.dataAplicacao).toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + })} + + + {ajuste.tipo === 'abonar' + ? 'Abonar' + : ajuste.tipo === 'descontar' + ? 'Descontar' + : 'Compensar'} + + +
+ + {ajuste.motivoTipo === 'atestado' + ? 'Atestado Médico' + : ajuste.motivoTipo === 'licenca' + ? 'Licença' + : ajuste.motivoTipo === 'ausencia' + ? 'Ausência' + : 'Manual'} + + {#if ajuste.motivoDescricao} + + {ajuste.motivoDescricao} + + {/if} +
+
+ = 0 ? 'text-success' : 'text-error'} + > + {ajuste.valorMinutos >= 0 ? '+' : ''} + {Math.floor(Math.abs(ajuste.valorMinutos) / 60)}h{' '} + {Math.abs(ajuste.valorMinutos) % 60}min + + {ajuste.gestor?.nome || 'Sistema'} + {#if ajuste.aplicado} + + + Aplicado + + {:else} + + + Pendente + + {/if} +
+
+
+
+ {/if} + + + {#if inconsistencias && inconsistencias.length > 0} +
+
+

+ + Inconsistências Detectadas - {formatarMes(mesSelecionado)} +

+
+ {#each inconsistencias as inconsistencia} +
+
+
+
+ + {inconsistencia.status === 'resolvida' + ? 'Resolvida' + : inconsistencia.status === 'ignorada' + ? 'Ignorada' + : 'Pendente'} + + + {inconsistencia.tipo === 'ponto_com_atestado' + ? 'Registro de Ponto com Atestado' + : inconsistencia.tipo === 'ponto_com_licenca' + ? 'Registro de Ponto com Licença' + : inconsistencia.tipo === 'ponto_com_ausencia' + ? 'Registro de Ponto com Ausência' + : inconsistencia.tipo === 'registro_duplicado' + ? 'Registro Duplicado' + : inconsistencia.tipo === 'sequencia_invalida' + ? 'Sequência Inválida' + : 'Saldo Inconsistente'} + +
+

{inconsistencia.descricao}

+

+ Detectada em:{' '} + {new Date(inconsistencia.dataDetectada).toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + })} +

+
+ {#if inconsistencia.status === 'pendente'} + + {:else if inconsistencia.status === 'resolvida'} + + {/if} +
+
+ {/each} +
+
+
+ {/if} + + + {#if bancoMensal && (bancoMensal.totalAjustes || bancoMensal.totalAbonos || bancoMensal.totalDescontos || bancoMensal.inconsistenciasResolvidas !== undefined)} +
+
+

+ + Resumo de Ajustes e Inconsistências +

+
+ {#if bancoMensal.totalAjustes !== undefined} +
+

Total de Ajustes

+

+ {Math.floor(Math.abs(bancoMensal.totalAjustes) / 60)}h{' '} + {Math.abs(bancoMensal.totalAjustes) % 60}min +

+
+ {/if} + {#if bancoMensal.totalAbonos !== undefined} +
+

Total de Abonos

+

+ +{Math.floor(bancoMensal.totalAbonos / 60)}h{' '} + {bancoMensal.totalAbonos % 60}min +

+
+ {/if} + {#if bancoMensal.totalDescontos !== undefined} +
+

Total de Descontos

+

+ -{Math.floor(bancoMensal.totalDescontos / 60)}h{' '} + {bancoMensal.totalDescontos % 60}min +

+
+ {/if} + {#if bancoMensal.inconsistenciasResolvidas !== undefined} +
+

+ Inconsistências Resolvidas +

+

{bancoMensal.inconsistenciasResolvidas}

+
+ {/if} +
+
+
+ {/if} {:else}
diff --git a/apps/web/src/lib/components/ponto/TimePicker.svelte b/apps/web/src/lib/components/ponto/TimePicker.svelte new file mode 100644 index 0000000..5bbe728 --- /dev/null +++ b/apps/web/src/lib/components/ponto/TimePicker.svelte @@ -0,0 +1,163 @@ + + +
+ {#if label} +
{label}
+ {/if} + +
+ +
+ + + + horas +
+ + +
:
+ + +
+ + + + min +
+ + +
+ Total + {displayText} +
+
+
+ + + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte index b64a15c..aad3b70 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte @@ -18,7 +18,8 @@ Info, ArrowRight, Clock, - XCircle + XCircle, + TrendingUp } from 'lucide-svelte'; import type { Component } from 'svelte'; @@ -102,6 +103,12 @@ descricao: 'Gerencie períodos de dispensa de registro de ponto', href: '/recursos-humanos/controle-ponto/dispensa', Icon: XCircle + }, + { + nome: 'Banco de Horas', + descricao: 'Visão gerencial do banco de horas dos funcionários, com filtros, estatísticas e relatórios', + href: '/recursos-humanos/controle-ponto/banco-horas', + Icon: TrendingUp } ] }, diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/+page.svelte index 994e595..27a0d5c 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/+page.svelte @@ -1,5 +1,5 @@ @@ -16,7 +16,7 @@
-
+ diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/banco-horas/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/banco-horas/+page.svelte index 1dc6055..d88281c 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/banco-horas/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/banco-horas/+page.svelte @@ -12,18 +12,31 @@ FileText, Calendar, Search, - Filter + Filter, + Plus, + CheckCircle2, + XCircle, + Eye, + Edit, + AlertCircle } from 'lucide-svelte'; + import { useConvexClient } from 'convex-svelte'; import LineChart from '$lib/components/ti/charts/LineChart.svelte'; import jsPDF from 'jspdf'; import logoGovPE from '$lib/assets/logo_governo_PE.png'; + const client = useConvexClient(); + // Estados let mesSelecionado = $state( `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}` ); let funcionarioFiltro = $state(''); let apenasNegativos = $state(false); + let tipoDiaFiltro = $state(''); + let statusInconsistenciaFiltro = $state(''); + let mostrarModalAjuste = $state(false); + let funcionarioSelecionado = $state | null>(null); // Queries const funcionariosQuery = useQuery(api.funcionarios.listar, {}); @@ -35,7 +48,15 @@ funcionarioId: funcionarioFiltro ? (funcionarioFiltro as Id<'funcionarios'>) : undefined }); + // Query para inconsistências gerais + const inconsistenciasGeraisQuery = useQuery(api.pontos.listarInconsistenciasBancoHoras, { + status: statusInconsistenciaFiltro + ? (statusInconsistenciaFiltro as 'pendente' | 'resolvida' | 'ignorada') + : undefined + }); + const estatisticas = $derived(estatisticasQuery?.data); + const inconsistenciasGerais = $derived(inconsistenciasGeraisQuery?.data || []); // Função para formatar mês function formatarMes(mes: string): string { @@ -226,10 +247,55 @@
+ +
+
+

+ + Filtros Avançados +

+
+ +
+ + +
+ + +
+ + +
+
+
+
+ {#if estatisticasQuery?.isLoading}
+ {:else if estatisticasQuery?.error} +
+ + Erro ao carregar estatísticas: {estatisticasQuery.error.message || 'Erro desconhecido'} +
{:else if estatisticas}
@@ -308,11 +374,16 @@ Dias Trabalhados Horas Extras Déficit + Inconsistências + Ações {#each estatisticas.funcionarios as item} {#if !apenasNegativos || item.saldoFinalMinutos < 0} + {@const inconsistenciasFunc = inconsistenciasGerais.filter( + (i) => i.funcionarioId === item.funcionario._id + )} {Math.floor(item.horasDeficit / 60)}h {item.horasDeficit % 60}min + + {#if inconsistenciasFunc.length > 0} + + + {inconsistenciasFunc.length} + + {:else} + + + OK + + {/if} + + +
+ + + + +
+ {/if} {/each} @@ -375,6 +481,105 @@
+ + + {#if inconsistenciasGerais && inconsistenciasGerais.length > 0} +
+
+

+ + Inconsistências Detectadas +

+
+ + + + + + + + + + + + + {#each inconsistenciasGerais as inconsistencia} + + + + + + + + + {/each} + +
FuncionárioTipoDescriçãoData DetectadaStatusAções
+
+
+ {inconsistencia.funcionario?.nome || 'N/A'} +
+ {#if inconsistencia.funcionario?.matricula} + + ({inconsistencia.funcionario.matricula}) + + {/if} +
+
+ + {inconsistencia.tipo === 'ponto_com_atestado' + ? 'Ponto + Atestado' + : inconsistencia.tipo === 'ponto_com_licenca' + ? 'Ponto + Licença' + : inconsistencia.tipo === 'ponto_com_ausencia' + ? 'Ponto + Ausência' + : inconsistencia.tipo === 'registro_duplicado' + ? 'Duplicado' + : inconsistencia.tipo === 'sequencia_invalida' + ? 'Sequência Inválida' + : 'Saldo Inconsistente'} + + {inconsistencia.descricao} + {new Date(inconsistencia.dataDetectada).toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + })} + + + {inconsistencia.status === 'resolvida' + ? 'Resolvida' + : inconsistencia.status === 'ignorada' + ? 'Ignorada' + : 'Pendente'} + + + + + +
+
+
+
+ {/if} {:else}
@@ -388,6 +593,40 @@ {/if}
+ +{#if mostrarModalAjuste && funcionarioSelecionado} + +{/if} + diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index 8ca4655..470247d 100644 --- a/apps/web/src/routes/(dashboard)/ti/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte @@ -307,6 +307,19 @@ { label: 'Direitos', variant: 'outline' } ] }, + { + title: 'Configurações de Banco de Horas', + description: + 'Configure limites, regras e alertas do sistema de banco de horas. Gerencie configurações gerais e alertas configuráveis.', + ctaLabel: 'Configurar Banco de Horas', + href: '/(dashboard)/ti/configuracoes-banco-horas', + palette: 'primary', + icon: 'clock', + highlightBadges: [ + { label: 'Alertas', variant: 'solid' }, + { label: 'Configurações', variant: 'outline' } + ] + }, { title: 'Configuração de Email', description: diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes-banco-horas/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes-banco-horas/+page.svelte new file mode 100644 index 0000000..9a6fd93 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-banco-horas/+page.svelte @@ -0,0 +1,1139 @@ + + +
+ +
+
+
+ +
+
+

Configurações de Banco de Horas

+

+ Configure limites, regras e alertas do sistema de banco de horas +

+
+
+
+ + + {#if mensagem} +
+
+ {#if mensagem.tipo === 'success'} + + {:else} + + {/if} + {mensagem.texto} +
+
+ {/if} + + +
+
+
+ +
+

Configurações Gerais

+
+ +
+ +
+ +

+ Máximo de horas extras acumuladas permitidas +

+ { + limiteSaldoPositivoHoras = h; + limiteSaldoPositivoMinutos = m; + }} + disabled={processando} + /> +
+ + +
+ +

+ Máximo de débito permitido no banco de horas +

+ { + limiteSaldoNegativoHoras = h; + limiteSaldoNegativoMinutos = m; + }} + disabled={processando} + /> +
+ + +
+ +
+ +

+ Incluir automaticamente atestados, licenças e ausências no cálculo +

+
+
+ + +
+ +

+ Frequência de verificação automática do sistema +

+ +
+
+ +
+ +
+
+ + +
+
+
+
+ +
+
+

Configuração de Alertas

+

+ Gerencie alertas e notificações do sistema de banco de horas +

+
+
+ {#if tiposAlertaDisponiveisParaCriar.length > 0} + + {/if} +
+ + {#if alertas.length === 0} +
+ +

Nenhum alerta configurado

+

+ Clique em "Novo Alerta" para começar a configurar notificações +

+
+ {:else} +
+ {#each alertas as alerta (alerta._id)} +
+ +
+
+ {getTipoAlertaIcon(alerta.tipoAlerta)} +
+

+ {getTipoAlertaLabel(alerta.tipoAlerta)} +

+

+ {getTipoAlertaDescricao(alerta.tipoAlerta)} +

+
+
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+
+ + + {#if alerta.enviarEmail} +
+ +
+ {#if alerta.destinatariosEmail.length > 0} + {#if usuariosQuery?.data && Array.isArray(usuariosQuery.data)} + {#each alerta.destinatariosEmail as destinatarioId} + {@const usuario = usuariosQuery.data.find((u) => u._id === destinatarioId)} + {#if usuario} +
+ + {usuario.nome} + +
+ {/if} + {/each} + {:else} +

Carregando...

+ {/if} + {:else} +

+ Enviará para o gestor padrão do funcionário +

+ {/if} +
+ +
+ {/if} + + + {#if alerta.enviarChat} +
+ +
+ {#if alerta.destinatariosChat.length > 0} + {#if usuariosQuery?.data && Array.isArray(usuariosQuery.data)} + {#each alerta.destinatariosChat as destinatarioId} + {@const usuario = usuariosQuery.data.find((u) => u._id === destinatarioId)} + {#if usuario} +
+ + {usuario.nome} + +
+ {/if} + {/each} + {:else} +

Carregando...

+ {/if} + {:else} +

+ Enviará para o gestor padrão do funcionário +

+ {/if} +
+ +
+ {/if} + + + {#if alerta.limiteMinutos !== undefined} +
+ +
+ + + {Math.floor((alerta.limiteMinutos || 0) / 60)}h{' '} + {(alerta.limiteMinutos || 0) % 60}min + +
+
+ {/if} +
+
+ {/each} +
+ {/if} +
+
+ + +{#if mostrarModalNovoAlerta} +
(mostrarModalNovoAlerta = false)} + role="dialog" + aria-modal="true" + > +
e.stopPropagation()} + > + +
+
+
+ +
+

Criar Novo Alerta

+
+ +
+ + +
+
+ +
+ + +

+ {getTipoAlertaDescricao(novoAlerta.tipoAlerta)} +

+
+ + +
+ + +
+ + +
+ +

+ Configure o limite em horas/minutos para disparar este alerta +

+ { + novoAlerta.limiteMinutosHoras = h; + novoAlerta.limiteMinutosMinutos = m; + }} + disabled={processando} + /> +
+ + +
+ +
+ + +
+
+ + + {#if novoAlerta.enviarEmail} +
+ +

+ Selecione os usuários que receberão este alerta por email. Deixe vazio para enviar ao gestor padrão. +

+
+ {#if usuariosQuery?.data && Array.isArray(usuariosQuery.data)} + {#each usuariosQuery.data as usuario (usuario._id)} + + {/each} + {:else} +

Carregando usuários...

+ {/if} +
+ {#if novoAlerta.destinatariosEmail.length > 0} +

+ {novoAlerta.destinatariosEmail.length} destinatário(s) selecionado(s) +

+ {/if} +
+ {/if} + + + {#if novoAlerta.enviarChat} +
+ +

+ Selecione os usuários que receberão este alerta por chat. Deixe vazio para enviar ao gestor padrão. +

+
+ {#if usuariosQuery?.data && Array.isArray(usuariosQuery.data)} + {#each usuariosQuery.data as usuario (usuario._id)} + + {/each} + {:else} +

Carregando usuários...

+ {/if} +
+ {#if novoAlerta.destinatariosChat.length > 0} +

+ {novoAlerta.destinatariosChat.length} destinatário(s) selecionado(s) +

+ {/if} +
+ {/if} + + +
+ +
+ +

+ O alerta começará a funcionar assim que for criado +

+
+
+
+
+ + +
+ + +
+
+
+{/if} + + +{#if mostrarSelecaoDestinatariosEmail} +
{ + mostrarSelecaoDestinatariosEmail = false; + alertaSelecionado = null; + destinatariosTemporarios = []; + }} + role="dialog" + aria-modal="true" + > +
e.stopPropagation()} + > +
+
+ +

Selecionar Destinatários de Email

+
+ +
+ +
+ {#if usuariosQuery?.data && Array.isArray(usuariosQuery.data)} + {#each usuariosQuery.data as usuario (usuario._id)} + + {/each} + {:else} +

Carregando usuários...

+ {/if} +
+ +
+ + +
+
+
+{/if} + + +{#if mostrarSelecaoDestinatariosChat} +
{ + mostrarSelecaoDestinatariosChat = false; + alertaSelecionado = null; + destinatariosTemporarios = []; + }} + role="dialog" + aria-modal="true" + > +
e.stopPropagation()} + > +
+
+ +

Selecionar Destinatários de Chat

+
+ +
+ +
+ {#if usuariosQuery?.data && Array.isArray(usuariosQuery.data)} + {#each usuariosQuery.data as usuario (usuario._id)} + + {/each} + {:else} +

Carregando usuários...

+ {/if} +
+ +
+ + +
+
+
+{/if} diff --git a/packages/backend/convex/atestadosLicencas.ts b/packages/backend/convex/atestadosLicencas.ts index 1122b2e..cd90e01 100644 --- a/packages/backend/convex/atestadosLicencas.ts +++ b/packages/backend/convex/atestadosLicencas.ts @@ -4,6 +4,7 @@ import { Id } from './_generated/dataModel'; import type { QueryCtx, MutationCtx } from './_generated/server'; import { registrarAtividade } from './logsAtividades'; import { getCurrentUserFunction } from './auth'; +import { internal } from './_generated/api'; // ========== HELPERS ========== @@ -26,6 +27,38 @@ function calcularDias(dataInicio: string, dataFim: string): number { return diffDays; } +/** + * Helper para recalcular banco de horas em um período + */ +async function recalcularBancoHorasPeriodo( + ctx: MutationCtx, + funcionarioId: Id<'funcionarios'>, + dataInicio: string, + dataFim: string +): Promise { + // Gerar todas as datas do período + const dataInicioObj = new Date(dataInicio); + const dataFimObj = new Date(dataFim); + const datas: string[] = []; + const dataAtual = new Date(dataInicioObj); + + while (dataAtual <= dataFimObj) { + const ano = dataAtual.getFullYear(); + const mes = String(dataAtual.getMonth() + 1).padStart(2, '0'); + const dia = String(dataAtual.getDate()).padStart(2, '0'); + datas.push(`${ano}-${mes}-${dia}`); + dataAtual.setDate(dataAtual.getDate() + 1); + } + + // Recalcular para cada data usando a mutation interna (agendar para execução assíncrona) + for (let i = 0; i < datas.length; i++) { + await ctx.scheduler.runAfter(i * 100, internal.pontos.recalcularBancoHorasData, { + funcionarioId, + data: datas[i]! + }); + } +} + // ========== QUERIES ========== /** @@ -780,6 +813,9 @@ export const criarAtestadoMedico = mutation({ atestadoId ); + // Recalcular banco de horas para todas as datas do período do atestado + await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim); + return atestadoId; } }); @@ -825,6 +861,9 @@ export const criarDeclaracaoComparecimento = mutation({ atestadoId ); + // Recalcular banco de horas para todas as datas do período da declaração + await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim); + return atestadoId; } }); @@ -878,6 +917,9 @@ export const criarLicencaMaternidade = mutation({ licencaId ); + // Recalcular banco de horas para todas as datas do período da licença + await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim); + return licencaId; } }); @@ -924,6 +966,9 @@ export const criarLicencaPaternidade = mutation({ licencaId ); + // Recalcular banco de horas para todas as datas do período da licença + await recalcularBancoHorasPeriodo(ctx, args.funcionarioId, args.dataInicio, args.dataFim); + return licencaId; } }); diff --git a/packages/backend/convex/ausencias.ts b/packages/backend/convex/ausencias.ts index 1a1ae5f..1f1cc44 100644 --- a/packages/backend/convex/ausencias.ts +++ b/packages/backend/convex/ausencias.ts @@ -2,6 +2,7 @@ import { v } from 'convex/values'; import { mutation, query } from './_generated/server'; import type { QueryCtx, MutationCtx } from './_generated/server'; import { api } from './_generated/api'; +import { internal } from './_generated/api'; import { Id, Doc } from './_generated/dataModel'; import { parseLocalDate, formatarDataBR } from './utils/datas'; import { getCurrentUserFunction } from './auth'; @@ -267,6 +268,36 @@ export const contarPendentesGestor = query({ } }); +// Helper: Recalcular banco de horas em um período +async function recalcularBancoHorasPeriodo( + ctx: MutationCtx, + funcionarioId: Id<'funcionarios'>, + dataInicio: string, + dataFim: string +): Promise { + // Gerar todas as datas do período + const dataInicioObj = new Date(dataInicio); + const dataFimObj = new Date(dataFim); + const datas: string[] = []; + const dataAtual = new Date(dataInicioObj); + + while (dataAtual <= dataFimObj) { + const ano = dataAtual.getFullYear(); + const mes = String(dataAtual.getMonth() + 1).padStart(2, '0'); + const dia = String(dataAtual.getDate()).padStart(2, '0'); + datas.push(`${ano}-${mes}-${dia}`); + dataAtual.setDate(dataAtual.getDate() + 1); + } + + // Recalcular para cada data usando a mutation interna (agendar para execução assíncrona) + for (let i = 0; i < datas.length; i++) { + await ctx.scheduler.runAfter(i * 100, internal.pontos.recalcularBancoHorasData, { + funcionarioId, + data: datas[i]! + }); + } +} + // Helper: Verificar se há sobreposição de datas function verificarSobreposicao( inicio1: string, @@ -641,6 +672,9 @@ export const aprovar = mutation({ } } + // Recalcular banco de horas para todas as datas do período da ausência aprovada + await recalcularBancoHorasPeriodo(ctx, solicitacao.funcionarioId, solicitacao.dataInicio, solicitacao.dataFim); + return null; } }); diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index befaff2..5410d4f 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -2,9 +2,9 @@ import { v } from 'convex/values'; import { mutation, query, internalMutation } from './_generated/server'; import type { MutationCtx, QueryCtx } from './_generated/server'; import { getCurrentUserFunction } from './auth'; -import type { Id } from './_generated/dataModel'; +import type { Id, Doc } from './_generated/dataModel'; import { validarLocalizacaoGeofencingInternal } from './enderecosMarcacao'; -import { internal } from './_generated/api'; +import { internal, api } from './_generated/api'; /** * Calcula distância entre duas coordenadas (fórmula de Haversine) @@ -1405,7 +1405,210 @@ function calcularHorasTrabalhadas( } /** - * Atualiza ou cria registro de banco de horas para o dia + * Verifica se há atestado médico ativo para o funcionário na data + */ +async function verificarAtestadoAtivo( + ctx: QueryCtx | MutationCtx, + funcionarioId: Id<'funcionarios'>, + data: string +): Promise<{ temAtestado: boolean; atestadoId?: string; motivo?: string }> { + const dataObj = new Date(data); + const atestados = await ctx.db + .query('atestados') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) + .collect(); + + for (const atestado of atestados) { + const dataInicio = new Date(atestado.dataInicio); + const dataFim = new Date(atestado.dataFim); + if (dataObj >= dataInicio && dataObj <= dataFim) { + return { + temAtestado: true, + atestadoId: atestado._id, + motivo: `Atestado Médico - CID: ${atestado.cid || 'N/A'}` + }; + } + } + + return { temAtestado: false }; +} + +/** + * Verifica se há licença ativa para o funcionário na data + */ +async function verificarLicencaAtiva( + ctx: QueryCtx | MutationCtx, + funcionarioId: Id<'funcionarios'>, + data: string +): Promise<{ temLicenca: boolean; licencaId?: string; motivo?: string }> { + const dataObj = new Date(data); + const licencas = await ctx.db + .query('licencas') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) + .collect(); + + for (const licenca of licencas) { + const dataInicio = new Date(licenca.dataInicio); + const dataFim = new Date(licenca.dataFim); + if (dataObj >= dataInicio && dataObj <= dataFim) { + const tipoLicenca = + licenca.tipo === 'maternidade' + ? 'Licença Maternidade' + : licenca.tipo === 'paternidade' + ? 'Licença Paternidade' + : 'Licença'; + return { + temLicenca: true, + licencaId: licenca._id, + motivo: tipoLicenca + }; + } + } + + return { temLicenca: false }; +} + +/** + * Verifica se há ausência aprovada para o funcionário na data + */ +async function verificarAusenciaAprovada( + ctx: QueryCtx | MutationCtx, + funcionarioId: Id<'funcionarios'>, + data: string +): Promise<{ temAusencia: boolean; ausenciaId?: string; motivo?: string }> { + const dataObj = new Date(data); + const ausencias = await ctx.db + .query('solicitacoesAusencias') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) + .filter((q) => q.eq(q.field('status'), 'aprovado')) + .collect(); + + for (const ausencia of ausencias) { + const dataInicio = new Date(ausencia.dataInicio); + const dataFim = new Date(ausencia.dataFim); + if (dataObj >= dataInicio && dataObj <= dataFim) { + return { + temAusencia: true, + ausenciaId: ausencia._id, + motivo: `Ausência Aprovada - ${ausencia.motivo}` + }; + } + } + + return { temAusencia: false }; +} + +/** + * Verifica ajustes manuais aplicados no dia + */ +async function obterAjustesManuais( + ctx: QueryCtx | MutationCtx, + funcionarioId: Id<'funcionarios'>, + data: string +): Promise }>> { + const ajustes = await ctx.db + .query('ajustesBancoHoras') + .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('dataAplicacao', data)) + .filter((q) => q.and(q.eq(q.field('aplicado'), true), q.eq(q.field('motivoTipo'), 'manual'))) + .collect(); + + return ajustes.map((a) => ({ + tipo: a.tipo, + valorMinutos: a.valorMinutos, + motivo: a.motivoDescricao, + ajusteId: a._id + })); +} + +/** + * Detecta inconsistências no banco de horas do dia + */ +async function detectarInconsistencias( + ctx: MutationCtx, + funcionarioId: Id<'funcionarios'>, + data: string, + registrosPontoIds: Array>, + atestadoInfo: { temAtestado: boolean; atestadoId?: string }, + licencaInfo: { temLicenca: boolean; licencaId?: string }, + ausenciaInfo: { temAusencia: boolean; ausenciaId?: string } +): Promise>> { + const inconsistenciaIds: Array> = []; + + // Verificar se há registro de ponto quando há atestado + if (atestadoInfo.temAtestado && registrosPontoIds.length > 0) { + const inconsistenciaId = await ctx.db.insert('inconsistenciasBancoHoras', { + funcionarioId, + tipo: 'ponto_com_atestado', + descricao: `Registro de ponto detectado em dia com atestado médico ativo`, + dataDetectada: data, + dataInconsistencia: data, + status: 'pendente', + criadoEm: Date.now() + }); + inconsistenciaIds.push(inconsistenciaId); + } + + // Verificar se há registro de ponto quando há licença + if (licencaInfo.temLicenca && registrosPontoIds.length > 0) { + const inconsistenciaId = await ctx.db.insert('inconsistenciasBancoHoras', { + funcionarioId, + tipo: 'ponto_com_licenca', + descricao: `Registro de ponto detectado em dia com licença ativa`, + dataDetectada: data, + dataInconsistencia: data, + status: 'pendente', + criadoEm: Date.now() + }); + inconsistenciaIds.push(inconsistenciaId); + } + + // Verificar se há registro de ponto quando há ausência aprovada + if (ausenciaInfo.temAusencia && registrosPontoIds.length > 0) { + const inconsistenciaId = await ctx.db.insert('inconsistenciasBancoHoras', { + funcionarioId, + tipo: 'ponto_com_ausencia', + descricao: `Registro de ponto detectado em dia com ausência aprovada`, + dataDetectada: data, + dataInconsistencia: data, + status: 'pendente', + criadoEm: Date.now() + }); + inconsistenciaIds.push(inconsistenciaId); + } + + return inconsistenciaIds; +} + +/** + * Aplica ajuste automático baseado em atestado, licença ou ausência + */ +async function aplicarAjusteAutomatico( + ctx: MutationCtx, + funcionarioId: Id<'funcionarios'>, + data: string, + cargaHorariaDiaria: number, + motivoTipo: 'atestado' | 'licenca' | 'ausencia', + motivoId: string, + motivoDescricao: string +): Promise> { + const ajusteId = await ctx.db.insert('ajustesBancoHoras', { + funcionarioId, + tipo: 'abonar', + motivoTipo, + motivoId, + motivoDescricao, + valorMinutos: cargaHorariaDiaria, // Abonar a carga horária completa do dia + dataAplicacao: data, + aplicado: true, + criadoEm: Date.now(), + aplicadoEm: Date.now() + }); + + return ajusteId; +} + +/** + * Atualiza ou cria registro de banco de horas para o dia (versão completa) */ async function atualizarBancoHoras( ctx: MutationCtx, @@ -1427,11 +1630,115 @@ async function atualizarBancoHoras( // Calcular carga horária esperada const cargaHorariaDiaria = calcularCargaHorariaDiaria(config); + // Verificar atestados, licenças e ausências ativos + const [atestadoInfo, licencaInfo, ausenciaInfo, ajustesManuais] = await Promise.all([ + verificarAtestadoAtivo(ctx, funcionarioId, data), + verificarLicencaAtiva(ctx, funcionarioId, data), + verificarAusenciaAprovada(ctx, funcionarioId, data), + obterAjustesManuais(ctx, funcionarioId, data) + ]); + // Calcular horas trabalhadas const horasTrabalhadas = calcularHorasTrabalhadas(registrosDoDia); - // Calcular saldo (positivo = horas extras, negativo = déficit) - const saldoMinutos = horasTrabalhadas - cargaHorariaDiaria; + // Determinar tipo do dia e motivo + let tipoDia: 'normal' | 'atestado' | 'licenca' | 'ausencia' | 'abonado' | 'descontado' = 'normal'; + let motivoAbono: string | undefined = undefined; + const ajustesIds: Array> = []; + + // Aplicar ajustes automáticos se houver atestado, licença ou ausência + if (atestadoInfo.temAtestado) { + tipoDia = 'atestado'; + motivoAbono = atestadoInfo.motivo; + if (atestadoInfo.atestadoId) { + const ajusteId = await aplicarAjusteAutomatico( + ctx, + funcionarioId, + data, + cargaHorariaDiaria, + 'atestado', + atestadoInfo.atestadoId, + atestadoInfo.motivo || 'Atestado Médico' + ); + ajustesIds.push(ajusteId); + } + } else if (licencaInfo.temLicenca) { + tipoDia = 'licenca'; + motivoAbono = licencaInfo.motivo; + if (licencaInfo.licencaId) { + const ajusteId = await aplicarAjusteAutomatico( + ctx, + funcionarioId, + data, + cargaHorariaDiaria, + 'licenca', + licencaInfo.licencaId, + licencaInfo.motivo || 'Licença' + ); + ajustesIds.push(ajusteId); + } + } else if (ausenciaInfo.temAusencia) { + tipoDia = 'ausencia'; + motivoAbono = ausenciaInfo.motivo; + if (ausenciaInfo.ausenciaId) { + const ajusteId = await aplicarAjusteAutomatico( + ctx, + funcionarioId, + data, + cargaHorariaDiaria, + 'ausencia', + ausenciaInfo.ausenciaId, + ausenciaInfo.motivo || 'Ausência Aprovada' + ); + ajustesIds.push(ajusteId); + } + } + + // Aplicar ajustes manuais + let ajusteManualTotal = 0; + for (const ajuste of ajustesManuais) { + ajusteManualTotal += ajuste.valorMinutos; + if (ajuste.tipo === 'abonar') { + if (tipoDia === 'normal') { + tipoDia = 'abonado'; + } + if (!motivoAbono) { + motivoAbono = ajuste.motivo || 'Abono Manual'; + } + } else if (ajuste.tipo === 'descontar') { + if (tipoDia === 'normal') { + tipoDia = 'descontado'; + } + } + // Adicionar ID do ajuste manual + if (ajuste.ajusteId) { + ajustesIds.push(ajuste.ajusteId); + } + } + + // Calcular saldo considerando ajustes + // Saldo base = horas trabalhadas - carga horária + let saldoMinutos = horasTrabalhadas - cargaHorariaDiaria; + + // Adicionar ajustes automáticos (abonos por atestado/licença/ausência) + if (atestadoInfo.temAtestado || licencaInfo.temLicenca || ausenciaInfo.temAusencia) { + saldoMinutos += cargaHorariaDiaria; // Abonar carga horária completa + } + + // Adicionar/subtrair ajustes manuais + saldoMinutos += ajusteManualTotal; + + // Detectar inconsistências + const registrosPontoIds = registrosDoDia.map((r) => r._id); + const inconsistenciaIds = await detectarInconsistencias( + ctx, + funcionarioId, + data, + registrosPontoIds, + atestadoInfo, + licencaInfo, + ausenciaInfo + ); // Buscar banco de horas existente const bancoHorasExistente = await ctx.db @@ -1439,8 +1746,6 @@ async function atualizarBancoHoras( .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) .first(); - const registrosPontoIds = registrosDoDia.map((r) => r._id); - if (bancoHorasExistente) { // Atualizar existente await ctx.db.patch(bancoHorasExistente._id, { @@ -1448,6 +1753,10 @@ async function atualizarBancoHoras( horasTrabalhadas, saldoMinutos, registrosPontoIds, + ajustesIds: ajustesIds.length > 0 ? ajustesIds : undefined, + motivoAbono, + tipoDia, + inconsistenciasIds: inconsistenciaIds.length > 0 ? inconsistenciaIds : undefined, calculadoEm: Date.now() }); } else { @@ -1459,6 +1768,10 @@ async function atualizarBancoHoras( horasTrabalhadas, saldoMinutos, registrosPontoIds, + ajustesIds: ajustesIds.length > 0 ? ajustesIds : undefined, + motivoAbono, + tipoDia, + inconsistenciasIds: inconsistenciaIds.length > 0 ? inconsistenciaIds : undefined, calculadoEm: Date.now() }); } @@ -1637,6 +1950,41 @@ async function calcularBancoHorasMensal( .reduce((acc, bh) => acc + bh.saldoMinutos, 0) ); + // Calcular totais de ajustes, abonos e descontos + let totalAjustes = 0; + let totalAbonos = 0; + let totalDescontos = 0; + let inconsistenciasResolvidas = 0; + + for (const bh of bancosHorasDoMes) { + // Contar ajustes + if (bh.ajustesIds && bh.ajustesIds.length > 0) { + const ajustes = await Promise.all( + bh.ajustesIds.map((id) => ctx.db.get(id)) + ); + for (const ajuste of ajustes) { + if (ajuste) { + totalAjustes += Math.abs(ajuste.valorMinutos); + if (ajuste.tipo === 'abonar') { + totalAbonos += ajuste.valorMinutos; + } else if (ajuste.tipo === 'descontar') { + totalDescontos += Math.abs(ajuste.valorMinutos); + } + } + } + } + + // Contar inconsistências resolvidas + if (bh.inconsistenciasIds && bh.inconsistenciasIds.length > 0) { + const inconsistencias = await Promise.all( + bh.inconsistenciasIds.map((id) => ctx.db.get(id)) + ); + inconsistenciasResolvidas += inconsistencias.filter( + (i) => i && i.status === 'resolvida' + ).length; + } + } + const agora = Date.now(); // Buscar ou criar registro mensal @@ -1656,6 +2004,10 @@ async function calcularBancoHorasMensal( diasTrabalhados, horasExtras, horasDeficit, + totalAjustes, + totalAbonos, + totalDescontos, + inconsistenciasResolvidas, atualizadoEm: agora }); } else { @@ -1669,6 +2021,10 @@ async function calcularBancoHorasMensal( diasTrabalhados, horasExtras, horasDeficit, + totalAjustes, + totalAbonos, + totalDescontos, + inconsistenciasResolvidas, calculadoEm: agora, atualizadoEm: agora }); @@ -2063,6 +2419,12 @@ export const ajustarBancoHoras = mutation({ motivoDescricao: v.optional(v.string()), observacoes: v.optional(v.string()) }, + returns: v.object({ + success: v.boolean(), + homologacaoId: v.id('homologacoesPonto'), + ajusteId: v.id('ajustesBancoHoras'), + ajusteMinutos: v.number() + }), handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { @@ -2086,6 +2448,22 @@ export const ajustarBancoHoras = mutation({ // Buscar banco de horas mais recente ou criar um registro de ajuste const hoje = new Date().toISOString().split('T')[0]!; + + // Criar registro de ajuste na nova tabela + const ajusteId = await ctx.db.insert('ajustesBancoHoras', { + funcionarioId: args.funcionarioId, + tipo: args.tipoAjuste, + motivoTipo: 'manual', + motivoId: args.motivoId, + motivoDescricao: args.motivoDescricao || `Ajuste ${args.tipoAjuste}`, + valorMinutos: ajusteFinal, + dataAplicacao: hoje, + gestorId: usuario._id, + observacoes: args.observacoes, + aplicado: false, + criadoEm: Date.now() + }); + const bancoHorasAtual = await ctx.db .query('bancoHoras') .withIndex('by_funcionario_data', (q) => @@ -2094,9 +2472,12 @@ export const ajustarBancoHoras = mutation({ .first(); if (bancoHorasAtual) { - // Atualizar saldo do dia atual + // Atualizar saldo do dia atual e adicionar ajuste await ctx.db.patch(bancoHorasAtual._id, { - saldoMinutos: bancoHorasAtual.saldoMinutos + ajusteFinal + saldoMinutos: bancoHorasAtual.saldoMinutos + ajusteFinal, + ajustesIds: [...(bancoHorasAtual.ajustesIds || []), ajusteId], + tipoDia: args.tipoAjuste === 'abonar' ? 'abonado' : args.tipoAjuste === 'descontar' ? 'descontado' : bancoHorasAtual.tipoDia, + motivoAbono: args.motivoDescricao || bancoHorasAtual.motivoAbono }); } else { // Criar novo registro de banco de horas para o ajuste @@ -2118,15 +2499,24 @@ export const ajustarBancoHoras = mutation({ horasTrabalhadas: 0, saldoMinutos: ajusteFinal, registrosPontoIds: [], + ajustesIds: [ajusteId], + tipoDia: args.tipoAjuste === 'abonar' ? 'abonado' : 'descontado', + motivoAbono: args.motivoDescricao || `Ajuste ${args.tipoAjuste}`, calculadoEm: Date.now() }); } + // Marcar ajuste como aplicado + await ctx.db.patch(ajusteId, { + aplicado: true, + aplicadoEm: Date.now() + }); + // Recalcular banco de horas mensal após ajuste const mes = hoje.substring(0, 7); // YYYY-MM await calcularBancoHorasMensal(ctx, args.funcionarioId, mes); - // Criar registro de homologação + // Criar registro de homologação (mantido para compatibilidade) const homologacaoId = await ctx.db.insert('homologacoesPonto', { funcionarioId: args.funcionarioId, gestorId: usuario._id, @@ -2142,7 +2532,7 @@ export const ajustarBancoHoras = mutation({ criadoEm: Date.now() }); - return { success: true, homologacaoId, ajusteMinutos: ajusteFinal }; + return { success: true, homologacaoId, ajusteId, ajusteMinutos: ajusteFinal }; } }); @@ -2727,3 +3117,1222 @@ export const verificarDispensaAtiva = query({ }; } }); + +// ========== QUERIES E MUTATIONS DO SISTEMA AVANÇADO DE BANCO DE HORAS ========== + +/** + * Obtém banco de horas completo com todos os detalhes e ajustes + */ +export const obterBancoHorasCompleto = query({ + args: { + funcionarioId: v.id('funcionarios'), + data: v.string() // YYYY-MM-DD + }, + returns: v.object({ + bancoHoras: v.union( + v.object({ + _id: v.id('bancoHoras'), + funcionarioId: v.id('funcionarios'), + data: v.string(), + cargaHorariaDiaria: v.number(), + horasTrabalhadas: v.number(), + saldoMinutos: v.number(), + tipoDia: v.optional( + v.union( + v.literal('normal'), + v.literal('atestado'), + v.literal('licenca'), + v.literal('ausencia'), + v.literal('abonado'), + v.literal('descontado') + ) + ), + motivoAbono: v.optional(v.string()), + ajustes: v.array( + v.object({ + _id: v.id('ajustesBancoHoras'), + tipo: v.union(v.literal('abonar'), v.literal('descontar'), v.literal('compensar')), + valorMinutos: v.number(), + motivoDescricao: v.optional(v.string()), + motivoTipo: v.optional( + v.union(v.literal('atestado'), v.literal('licenca'), v.literal('ausencia'), v.literal('manual')) + ) + }) + ), + inconsistencias: v.array( + v.object({ + _id: v.id('inconsistenciasBancoHoras'), + tipo: v.union( + v.literal('ponto_com_atestado'), + v.literal('ponto_com_licenca'), + v.literal('ponto_com_ausencia'), + v.literal('registro_duplicado'), + v.literal('sequencia_invalida'), + v.literal('saldo_inconsistente') + ), + descricao: v.string(), + status: v.union(v.literal('pendente'), v.literal('resolvida'), v.literal('ignorada')) + }) + ) + }), + v.null() + ) + }), + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // Verificar se é o próprio funcionário ou tem permissão + if (usuario.funcionarioId !== args.funcionarioId) { + // TODO: Verificar permissão de RH + } + + const bancoHoras = await ctx.db + .query('bancoHoras') + .withIndex('by_funcionario_data', (q) => + q.eq('funcionarioId', args.funcionarioId).eq('data', args.data) + ) + .first(); + + if (!bancoHoras) { + return { bancoHoras: null }; + } + + // Buscar ajustes + const ajustes = bancoHoras.ajustesIds + ? await Promise.all(bancoHoras.ajustesIds.map((id) => ctx.db.get(id))) + : []; + const ajustesFiltrados = ajustes.filter((a): a is NonNullable => a !== null); + + // Buscar inconsistências + const inconsistencias = bancoHoras.inconsistenciasIds + ? await Promise.all(bancoHoras.inconsistenciasIds.map((id) => ctx.db.get(id))) + : []; + const inconsistenciasFiltradas = inconsistencias.filter( + (i): i is NonNullable => i !== null + ); + + return { + bancoHoras: { + _id: bancoHoras._id, + funcionarioId: bancoHoras.funcionarioId, + data: bancoHoras.data, + cargaHorariaDiaria: bancoHoras.cargaHorariaDiaria, + horasTrabalhadas: bancoHoras.horasTrabalhadas, + saldoMinutos: bancoHoras.saldoMinutos, + tipoDia: bancoHoras.tipoDia, + motivoAbono: bancoHoras.motivoAbono, + ajustes: ajustesFiltrados.map((a) => ({ + _id: a._id, + tipo: a.tipo, + valorMinutos: a.valorMinutos, + motivoDescricao: a.motivoDescricao, + motivoTipo: a.motivoTipo + })), + inconsistencias: inconsistenciasFiltradas.map((i) => ({ + _id: i._id, + tipo: i.tipo, + descricao: i.descricao, + status: i.status + })) + } + }; + } +}); + +/** + * Lista ajustes de banco de horas de um funcionário/período + */ +export const listarAjustesBancoHoras = query({ + args: { + funcionarioId: v.id('funcionarios'), + dataInicio: v.optional(v.string()), // YYYY-MM-DD + dataFim: v.optional(v.string()) // YYYY-MM-DD + }, + returns: v.array( + v.object({ + _id: v.id('ajustesBancoHoras'), + tipo: v.union(v.literal('abonar'), v.literal('descontar'), v.literal('compensar')), + valorMinutos: v.number(), + motivoTipo: v.optional( + v.union(v.literal('atestado'), v.literal('licenca'), v.literal('ausencia'), v.literal('manual')) + ), + motivoDescricao: v.optional(v.string()), + dataAplicacao: v.string(), + aplicado: v.boolean(), + gestor: v.union( + v.object({ + nome: v.string() + }), + v.null() + ) + }) + ), + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // Verificar se é o próprio funcionário ou tem permissão + if (usuario.funcionarioId !== args.funcionarioId) { + // TODO: Verificar permissão de RH + } + + let query = ctx.db + .query('ajustesBancoHoras') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)); + + // Filtrar por período se fornecido + if (args.dataInicio || args.dataFim) { + query = query.filter((q) => { + const data = q.field('dataAplicacao'); + if (args.dataInicio && args.dataFim) { + return q.and(q.gte(data, args.dataInicio), q.lte(data, args.dataFim)); + } else if (args.dataInicio) { + return q.gte(data, args.dataInicio); + } else if (args.dataFim) { + return q.lte(data, args.dataFim); + } + return true; + }); + } + + const ajustes = await query.order('desc').collect(); + + // Buscar informações dos gestores + const ajustesComDetalhes = await Promise.all( + ajustes.map(async (ajuste) => { + const gestor = ajuste.gestorId ? await ctx.db.get(ajuste.gestorId) : null; + return { + _id: ajuste._id, + tipo: ajuste.tipo, + valorMinutos: ajuste.valorMinutos, + motivoTipo: ajuste.motivoTipo, + motivoDescricao: ajuste.motivoDescricao, + dataAplicacao: ajuste.dataAplicacao, + aplicado: ajuste.aplicado, + gestor: gestor ? { nome: gestor.nome } : null + }; + }) + ); + + return ajustesComDetalhes; + } +}); + +/** + * Lista inconsistências detectadas + */ +export const listarInconsistenciasBancoHoras = query({ + args: { + funcionarioId: v.optional(v.id('funcionarios')), + status: v.optional(v.union(v.literal('pendente'), v.literal('resolvida'), v.literal('ignorada'))) + }, + returns: v.array( + v.object({ + _id: v.id('inconsistenciasBancoHoras'), + funcionarioId: v.id('funcionarios'), + tipo: v.union( + v.literal('ponto_com_atestado'), + v.literal('ponto_com_licenca'), + v.literal('ponto_com_ausencia'), + v.literal('registro_duplicado'), + v.literal('sequencia_invalida'), + v.literal('saldo_inconsistente') + ), + descricao: v.string(), + dataDetectada: v.string(), + dataInconsistencia: v.string(), + status: v.union(v.literal('pendente'), v.literal('resolvida'), v.literal('ignorada')), + funcionario: v.union( + v.object({ + nome: v.string(), + matricula: v.optional(v.string()) + }), + v.null() + ) + }) + ), + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + let inconsistencias: Doc<'inconsistenciasBancoHoras'>[]; + + // Usar índice composto se ambos os filtros estiverem presentes + if (args.funcionarioId && args.status) { + const funcionarioId = args.funcionarioId; + const status = args.status; + inconsistencias = await ctx.db + .query('inconsistenciasBancoHoras') + .withIndex('by_funcionario_status', (q) => + q.eq('funcionarioId', funcionarioId).eq('status', status) + ) + .order('desc') + .collect(); + } else if (args.funcionarioId) { + // Filtrar apenas por funcionário + const funcionarioId = args.funcionarioId; + inconsistencias = await ctx.db + .query('inconsistenciasBancoHoras') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId)) + .order('desc') + .collect(); + } else if (args.status) { + // Filtrar apenas por status + const status = args.status; + inconsistencias = await ctx.db + .query('inconsistenciasBancoHoras') + .withIndex('by_status', (q) => q.eq('status', status)) + .order('desc') + .collect(); + } else { + // Sem filtros, usar full table scan e ordenar manualmente + inconsistencias = await ctx.db.query('inconsistenciasBancoHoras').collect(); + // Ordenar por data de detecção (mais recente primeiro) + inconsistencias = inconsistencias.sort((a, b) => { + const dataA = new Date(a.dataDetectada).getTime(); + const dataB = new Date(b.dataDetectada).getTime(); + return dataB - dataA; // Descendente + }); + } + + // Buscar informações dos funcionários + const inconsistenciasComDetalhes = await Promise.all( + inconsistencias.map(async (inconsistencia: Doc<'inconsistenciasBancoHoras'>) => { + const funcionario = (await ctx.db.get(inconsistencia.funcionarioId)) as Doc<'funcionarios'> | null; + return { + _id: inconsistencia._id, + funcionarioId: inconsistencia.funcionarioId, + tipo: inconsistencia.tipo, + descricao: inconsistencia.descricao, + dataDetectada: inconsistencia.dataDetectada, + dataInconsistencia: inconsistencia.dataInconsistencia, + status: inconsistencia.status, + funcionario: funcionario + ? { + nome: funcionario.nome, + matricula: funcionario.matricula + } + : null + }; + }) + ); + + return inconsistenciasComDetalhes; + } +}); + +/** + * Obtém configurações do sistema de banco de horas + */ +export const obterConfiguracaoBancoHoras = query({ + args: {}, + returns: v.union( + v.object({ + limiteSaldoPositivoMinutos: v.optional(v.number()), + limiteSaldoNegativoMinutos: v.optional(v.number()), + considerarAjustesAutomaticos: v.optional(v.boolean()), + periodicidadeVerificacao: v.optional(v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal'))) + }), + v.null() + ), + handler: async (ctx) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar permissão de TI + + const config = await ctx.db + .query('configuracaoBancoHoras') + .order('desc') + .first(); + + if (!config) { + return null; + } + + return { + limiteSaldoPositivoMinutos: config.limiteSaldoPositivoMinutos, + limiteSaldoNegativoMinutos: config.limiteSaldoNegativoMinutos, + considerarAjustesAutomaticos: config.considerarAjustesAutomaticos, + periodicidadeVerificacao: config.periodicidadeVerificacao + }; + } +}); + +/** + * Obtém alertas configurados + */ +export const obterAlertasConfigurados = query({ + args: {}, + returns: v.array( + v.object({ + _id: v.id('alertasBancoHoras'), + tipoAlerta: v.union( + v.literal('saldo_negativo'), + v.literal('saldo_negativo_critico'), + v.literal('inconsistencia_detectada'), + v.literal('dias_sem_registro'), + v.literal('limite_saldo_excedido') + ), + periodicidade: v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal')), + enviarEmail: v.boolean(), + enviarChat: v.boolean(), + destinatariosEmail: v.optional(v.array(v.id('usuarios'))), + destinatariosChat: v.optional(v.array(v.id('usuarios'))), + threshold: v.optional(v.number()), + limiteMinutos: v.optional(v.number()), + ativo: v.boolean() + }) + ), + handler: async (ctx) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar permissão de TI + + // Retornar todos os alertas (ativos e inativos) para permitir edição + const alertas = await ctx.db.query('alertasBancoHoras').collect(); + + return alertas.map((a) => ({ + _id: a._id, + tipoAlerta: a.tipoAlerta, + periodicidade: a.periodicidade, + enviarEmail: a.enviarEmail, + enviarChat: a.enviarChat, + destinatariosEmail: a.destinatariosEmail || [], + destinatariosChat: a.destinatariosChat || [], + threshold: a.threshold, + limiteMinutos: a.limiteMinutos, + ativo: a.ativo + })); + } +}); + +/** + * Verifica inconsistências para um funcionário/período + */ +export const verificarInconsistencias = query({ + args: { + funcionarioId: v.id('funcionarios'), + dataInicio: v.optional(v.string()), + dataFim: v.optional(v.string()) + }, + returns: v.array( + v.object({ + _id: v.id('inconsistenciasBancoHoras'), + tipo: v.union( + v.literal('ponto_com_atestado'), + v.literal('ponto_com_licenca'), + v.literal('ponto_com_ausencia'), + v.literal('registro_duplicado'), + v.literal('sequencia_invalida'), + v.literal('saldo_inconsistente') + ), + descricao: v.string(), + dataDetectada: v.string(), + status: v.union(v.literal('pendente'), v.literal('resolvida'), v.literal('ignorada')) + }) + ), + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // Verificar se é o próprio funcionário ou tem permissão + if (usuario.funcionarioId !== args.funcionarioId) { + // TODO: Verificar permissão de RH + } + + let query = ctx.db + .query('inconsistenciasBancoHoras') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)); + + // Filtrar por período se fornecido + if (args.dataInicio || args.dataFim) { + query = query.filter((q) => { + const data = q.field('dataDetectada'); + if (args.dataInicio && args.dataFim) { + return q.and(q.gte(data, args.dataInicio), q.lte(data, args.dataFim)); + } else if (args.dataInicio) { + return q.gte(data, args.dataInicio); + } else if (args.dataFim) { + return q.lte(data, args.dataFim); + } + return true; + }); + } + + const inconsistencias = await query.order('desc').collect(); + + return inconsistencias.map((i) => ({ + _id: i._id, + tipo: i.tipo, + descricao: i.descricao, + dataDetectada: i.dataDetectada, + status: i.status + })); + } +}); + +/** + * Cria ajuste manual de banco de horas (abonar/descontar) + */ +export const criarAjusteBancoHoras = mutation({ + args: { + funcionarioId: v.id('funcionarios'), + tipo: v.union(v.literal('abonar'), v.literal('descontar'), v.literal('compensar')), + valorHoras: v.number(), + valorMinutos: v.number(), + dataAplicacao: v.string(), // YYYY-MM-DD + motivoDescricao: v.string(), + observacoes: v.optional(v.string()) + }, + returns: v.object({ + ajusteId: v.id('ajustesBancoHoras'), + success: v.boolean() + }), + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // Verificar se é gestor do funcionário + const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, args.funcionarioId); + if (!isGestor) { + throw new Error('Você não tem permissão para criar ajuste de banco de horas para este funcionário'); + } + + // Calcular valor total em minutos + const valorTotalMinutos = args.valorHoras * 60 + args.valorMinutos; + const valorFinal = args.tipo === 'descontar' ? -valorTotalMinutos : valorTotalMinutos; + + // Criar ajuste + const ajusteId = await ctx.db.insert('ajustesBancoHoras', { + funcionarioId: args.funcionarioId, + tipo: args.tipo, + motivoTipo: 'manual', + motivoDescricao: args.motivoDescricao, + valorMinutos: valorFinal, + dataAplicacao: args.dataAplicacao, + gestorId: usuario._id, + observacoes: args.observacoes, + aplicado: false, // Será aplicado no próximo recálculo + criadoEm: Date.now() + }); + + // Aplicar ajuste imediatamente + const bancoHorasAtual = await ctx.db + .query('bancoHoras') + .withIndex('by_funcionario_data', (q) => + q.eq('funcionarioId', args.funcionarioId).eq('data', args.dataAplicacao) + ) + .first(); + + if (bancoHorasAtual) { + // Atualizar saldo do dia + await ctx.db.patch(bancoHorasAtual._id, { + saldoMinutos: bancoHorasAtual.saldoMinutos + valorFinal, + ajustesIds: [...(bancoHorasAtual.ajustesIds || []), ajusteId] + }); + } else { + // Criar novo registro de banco de horas para o dia + const config = await ctx.db + .query('configuracaoPonto') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + if (config) { + const cargaHorariaDiaria = calcularCargaHorariaDiaria(config); + await ctx.db.insert('bancoHoras', { + funcionarioId: args.funcionarioId, + data: args.dataAplicacao, + cargaHorariaDiaria, + horasTrabalhadas: 0, + saldoMinutos: valorFinal, + registrosPontoIds: [], + ajustesIds: [ajusteId], + tipoDia: args.tipo === 'abonar' ? 'abonado' : 'descontado', + motivoAbono: args.motivoDescricao, + calculadoEm: Date.now() + }); + } + } + + // Marcar ajuste como aplicado + await ctx.db.patch(ajusteId, { + aplicado: true, + aplicadoEm: Date.now() + }); + + // Recalcular banco de horas mensal + const mes = args.dataAplicacao.substring(0, 7); + await calcularBancoHorasMensal(ctx, args.funcionarioId, mes); + + return { ajusteId, success: true }; + } +}); + +/** + * Resolve uma inconsistência + */ +export const resolverInconsistencia = mutation({ + args: { + inconsistenciaId: v.id('inconsistenciasBancoHoras'), + resolucao: v.string() + }, + returns: v.object({ + success: v.boolean() + }), + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + const inconsistencia = await ctx.db.get(args.inconsistenciaId); + if (!inconsistencia) { + throw new Error('Inconsistência não encontrada'); + } + + // Verificar se é gestor do funcionário + const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, inconsistencia.funcionarioId); + if (!isGestor) { + throw new Error('Você não tem permissão para resolver esta inconsistência'); + } + + // Atualizar inconsistência + await ctx.db.patch(args.inconsistenciaId, { + status: 'resolvida', + resolucao: args.resolucao, + resolvidoPor: usuario._id, + resolvidoEm: Date.now() + }); + + return { success: true }; + } +}); + +/** + * Atualiza configurações gerais do banco de horas + */ +export const atualizarConfiguracaoBancoHoras = mutation({ + args: { + limiteSaldoPositivoMinutos: v.optional(v.number()), + limiteSaldoNegativoMinutos: v.optional(v.number()), + considerarAjustesAutomaticos: v.optional(v.boolean()), + periodicidadeVerificacao: v.optional(v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal'))) + }, + returns: v.object({ + success: v.boolean(), + configId: v.id('configuracaoBancoHoras') + }), + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar permissão de TI + + // Buscar configuração existente ou criar nova + const configExistente = await ctx.db + .query('configuracaoBancoHoras') + .order('desc') + .first(); + + if (configExistente) { + await ctx.db.patch(configExistente._id, { + limiteSaldoPositivoMinutos: args.limiteSaldoPositivoMinutos, + limiteSaldoNegativoMinutos: args.limiteSaldoNegativoMinutos, + considerarAjustesAutomaticos: args.considerarAjustesAutomaticos, + periodicidadeVerificacao: args.periodicidadeVerificacao, + atualizadoPor: usuario._id, + atualizadoEm: Date.now() + }); + return { success: true, configId: configExistente._id }; + } else { + const configId = await ctx.db.insert('configuracaoBancoHoras', { + limiteSaldoPositivoMinutos: args.limiteSaldoPositivoMinutos, + limiteSaldoNegativoMinutos: args.limiteSaldoNegativoMinutos, + considerarAjustesAutomaticos: args.considerarAjustesAutomaticos ?? true, + periodicidadeVerificacao: args.periodicidadeVerificacao, + atualizadoPor: usuario._id, + atualizadoEm: Date.now() + }); + return { success: true, configId }; + } + } +}); + +/** + * Atualiza configuração de alerta específico + */ +export const criarAlertaBancoHoras = mutation({ + args: { + tipoAlerta: v.union( + v.literal('saldo_negativo'), + v.literal('saldo_negativo_critico'), + v.literal('inconsistencia_detectada'), + v.literal('dias_sem_registro'), + v.literal('limite_saldo_excedido') + ), + periodicidade: v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal')), + enviarEmail: v.boolean(), + enviarChat: v.boolean(), + destinatariosEmail: v.optional(v.array(v.id('usuarios'))), + destinatariosChat: v.optional(v.array(v.id('usuarios'))), + threshold: v.optional(v.number()), + limiteMinutos: v.optional(v.number()), + ativo: v.boolean() + }, + returns: v.object({ + success: v.boolean(), + alertaId: v.id('alertasBancoHoras') + }), + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar permissão de TI + + // Verificar se já existe alerta do mesmo tipo + const alertaExistente = await ctx.db + .query('alertasBancoHoras') + .withIndex('by_tipo', (q) => q.eq('tipoAlerta', args.tipoAlerta)) + .first(); + + if (alertaExistente) { + throw new Error('Já existe um alerta configurado para este tipo'); + } + + const alertaId = await ctx.db.insert('alertasBancoHoras', { + tipoAlerta: args.tipoAlerta, + periodicidade: args.periodicidade, + enviarEmail: args.enviarEmail, + enviarChat: args.enviarChat, + destinatariosEmail: args.destinatariosEmail || [], + destinatariosChat: args.destinatariosChat || [], + threshold: args.threshold, + limiteMinutos: args.limiteMinutos, + ativo: args.ativo, + criadoPor: usuario._id, + criadoEm: Date.now() + }); + + return { success: true, alertaId }; + } +}); + +export const atualizarConfiguracaoAlerta = mutation({ + args: { + alertaId: v.id('alertasBancoHoras'), + periodicidade: v.optional(v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal'))), + enviarEmail: v.optional(v.boolean()), + enviarChat: v.optional(v.boolean()), + destinatariosEmail: v.optional(v.array(v.id('usuarios'))), + destinatariosChat: v.optional(v.array(v.id('usuarios'))), + threshold: v.optional(v.number()), + limiteMinutos: v.optional(v.number()), + ativo: v.optional(v.boolean()) + }, + returns: v.object({ + success: v.boolean() + }), + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar permissão de TI + + const alerta = await ctx.db.get(args.alertaId); + if (!alerta) { + throw new Error('Alerta não encontrado'); + } + + await ctx.db.patch(args.alertaId, { + periodicidade: args.periodicidade ?? alerta.periodicidade, + enviarEmail: args.enviarEmail ?? alerta.enviarEmail, + enviarChat: args.enviarChat ?? alerta.enviarChat, + destinatariosEmail: args.destinatariosEmail !== undefined ? args.destinatariosEmail : alerta.destinatariosEmail, + destinatariosChat: args.destinatariosChat !== undefined ? args.destinatariosChat : alerta.destinatariosChat, + threshold: args.threshold, + limiteMinutos: args.limiteMinutos, + ativo: args.ativo ?? alerta.ativo, + atualizadoPor: usuario._id, + atualizadoEm: Date.now() + }); + + return { success: true }; + } +}); + +/** + * Recalcula banco de horas para um funcionário/período + */ +export const recalcularBancoHoras = mutation({ + args: { + funcionarioId: v.id('funcionarios'), + dataInicio: v.string(), // YYYY-MM-DD + dataFim: v.string() // YYYY-MM-DD + }, + returns: v.object({ + success: v.boolean(), + diasRecalculados: v.number() + }), + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // Verificar se é gestor do funcionário + const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, args.funcionarioId); + if (!isGestor) { + throw new Error('Você não tem permissão para recalcular banco de horas deste funcionário'); + } + + // Buscar configuração de ponto + const config = await ctx.db + .query('configuracaoPonto') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + if (!config) { + throw new Error('Configuração de ponto não encontrada'); + } + + // Gerar todas as datas do período + const dataInicioObj = new Date(args.dataInicio); + const dataFimObj = new Date(args.dataFim); + const datas: string[] = []; + const dataAtual = new Date(dataInicioObj); + + while (dataAtual <= dataFimObj) { + const ano = dataAtual.getFullYear(); + const mes = String(dataAtual.getMonth() + 1).padStart(2, '0'); + const dia = String(dataAtual.getDate()).padStart(2, '0'); + datas.push(`${ano}-${mes}-${dia}`); + dataAtual.setDate(dataAtual.getDate() + 1); + } + + // Recalcular para cada data + let diasRecalculados = 0; + for (const data of datas) { + await atualizarBancoHoras(ctx, args.funcionarioId, data, { + horarioEntrada: config.horarioEntrada, + horarioSaidaAlmoco: config.horarioSaidaAlmoco, + horarioRetornoAlmoco: config.horarioRetornoAlmoco, + horarioSaida: config.horarioSaida + }); + diasRecalculados++; + } + + return { success: true, diasRecalculados }; + } +}); + +/** + * Mutation interna para recalcular banco de horas de uma data específica + */ +export const recalcularBancoHorasData = internalMutation({ + args: { + funcionarioId: v.id('funcionarios'), + data: v.string() // YYYY-MM-DD + }, + returns: v.null(), + handler: async (ctx, args) => { + // Buscar configuração de ponto + const config = await ctx.db + .query('configuracaoPonto') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + if (!config) { + return null; + } + + // Recalcular banco de horas para a data + await atualizarBancoHoras(ctx, args.funcionarioId, args.data, { + horarioEntrada: config.horarioEntrada, + horarioSaidaAlmoco: config.horarioSaidaAlmoco, + horarioRetornoAlmoco: config.horarioRetornoAlmoco, + horarioSaida: config.horarioSaida + }); + + return null; + } +}); + +// ========== SISTEMA DE ALERTAS DE BANCO DE HORAS ========== + +/** + * Helper: Encontrar gestor do funcionário + */ +async function encontrarGestorDoFuncionarioParaAlerta( + ctx: QueryCtx | MutationCtx, + funcionarioId: Id<'funcionarios'> +): Promise | null> { + const funcionario = await ctx.db.get(funcionarioId); + if (!funcionario || !funcionario.gestorId) { + return null; + } + return funcionario.gestorId; +} + +/** + * Helper: Enviar alerta via chat + */ +async function enviarAlertaChat( + ctx: MutationCtx, + gestorId: Id<'usuarios'>, + titulo: string, + mensagem: string +): Promise { + // Criar notificação no sistema + await ctx.db.insert('notificacoes', { + usuarioId: gestorId, + tipo: 'nova_mensagem', + titulo, + descricao: mensagem, + lida: false, + criadaEm: Date.now() + }); +} + +/** + * Helper: Enviar alerta via email + */ +async function enviarAlertaEmail( + ctx: MutationCtx, + gestorId: Id<'usuarios'>, + titulo: string, + mensagem: string +): Promise { + const gestor = await ctx.db.get(gestorId); + if (!gestor || !gestor.email) { + return; + } + + // Obter URL do sistema + let urlSistema = process.env.FRONTEND_URL || 'http://localhost:5173'; + if (!urlSistema.match(/^https?:\/\//i)) { + urlSistema = `http://${urlSistema}`; + } + + // Enviar email usando template + await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, { + destinatario: gestor.email, + destinatarioId: gestorId, + templateCodigo: 'banco_horas_alerta', + variaveis: { + gestorNome: gestor.nome, + titulo, + mensagem, + urlSistema + }, + enviadoPor: gestorId + }); +} + +/** + * Detecta e envia alertas de banco de horas para um funcionário + */ +export const detectarEEnviarAlertasBancoHoras = internalMutation({ + args: { + funcionarioId: v.id('funcionarios') + }, + returns: v.object({ + alertasEnviados: v.number() + }), + handler: async (ctx, args) => { + // Buscar configurações de alertas ativas + const alertasConfigurados = await ctx.db + .query('alertasBancoHoras') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .collect(); + + if (alertasConfigurados.length === 0) { + return { alertasEnviados: 0 }; + } + + // Buscar gestor do funcionário + const gestorId = await encontrarGestorDoFuncionarioParaAlerta(ctx, args.funcionarioId); + if (!gestorId) { + return { alertasEnviados: 0 }; + } + + // Buscar banco de horas mensal atual + const hoje = new Date(); + const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`; + + const bancoMensal = await ctx.db + .query('bancoHorasMensal') + .withIndex('by_funcionario_mes', (q) => + q.eq('funcionarioId', args.funcionarioId).eq('mes', mesAtual) + ) + .first(); + + // Buscar inconsistências pendentes + const inconsistenciasPendentes = await ctx.db + .query('inconsistenciasBancoHoras') + .withIndex('by_funcionario_status', (q) => + q.eq('funcionarioId', args.funcionarioId).eq('status', 'pendente') + ) + .collect(); + + let alertasEnviados = 0; + + // Verificar cada tipo de alerta configurado + for (const alertaConfig of alertasConfigurados) { + let deveEnviar = false; + let titulo = ''; + let mensagem = ''; + + switch (alertaConfig.tipoAlerta) { + case 'saldo_negativo': + if (bancoMensal && bancoMensal.saldoFinalMinutos < 0) { + const horasNegativas = Math.abs(bancoMensal.saldoFinalMinutos) / 60; + if (!alertaConfig.limiteMinutos || Math.abs(bancoMensal.saldoFinalMinutos) >= alertaConfig.limiteMinutos) { + deveEnviar = true; + titulo = '⚠️ Alerta: Saldo Negativo de Banco de Horas'; + mensagem = `O funcionário possui saldo negativo acumulado de ${Math.floor(horasNegativas)}h ${Math.abs(bancoMensal.saldoFinalMinutos) % 60}min.`; + } + } + break; + + case 'saldo_negativo_critico': + if (bancoMensal && bancoMensal.saldoFinalMinutos < 0) { + const horasNegativas = Math.abs(bancoMensal.saldoFinalMinutos) / 60; + if (horasNegativas >= 8) { + deveEnviar = true; + titulo = '🚨 Alerta Crítico: Saldo Negativo Crítico de Banco de Horas'; + mensagem = `O funcionário possui saldo negativo crítico acumulado de ${Math.floor(horasNegativas)}h ${Math.abs(bancoMensal.saldoFinalMinutos) % 60}min. Ação imediata necessária.`; + } + } + break; + + case 'inconsistencia_detectada': + if (inconsistenciasPendentes.length > 0) { + deveEnviar = true; + titulo = '⚠️ Alerta: Inconsistências Detectadas no Banco de Horas'; + mensagem = `Foram detectadas ${inconsistenciasPendentes.length} inconsistência(s) no banco de horas do funcionário que precisam ser resolvidas.`; + } + break; + + case 'dias_sem_registro': + // Verificar últimos 7 dias + const ultimos7Dias: string[] = []; + for (let i = 0; i < 7; i++) { + const data = new Date(); + data.setDate(data.getDate() - i); + ultimos7Dias.push(data.toISOString().split('T')[0]!); + } + + const registrosRecentes = await ctx.db + .query('bancoHoras') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)) + .filter((q) => { + const data = q.field('data'); + return q.or(...ultimos7Dias.map((dia) => q.eq(data, dia))); + }) + .collect(); + + const diasComRegistro = new Set(registrosRecentes.map((r) => r.data)); + const diasSemRegistro = ultimos7Dias.filter((dia) => !diasComRegistro.has(dia)); + + if (diasSemRegistro.length >= 3) { + deveEnviar = true; + titulo = '⚠️ Alerta: Múltiplos Dias Sem Registro de Ponto'; + mensagem = `O funcionário não possui registro de ponto em ${diasSemRegistro.length} dos últimos 7 dias.`; + } + break; + + case 'limite_saldo_excedido': + if (bancoMensal) { + const config = await ctx.db + .query('configuracaoBancoHoras') + .order('desc') + .first(); + + if (config) { + if ( + config.limiteSaldoPositivoMinutos && + bancoMensal.saldoFinalMinutos > config.limiteSaldoPositivoMinutos + ) { + deveEnviar = true; + titulo = '⚠️ Alerta: Limite de Saldo Positivo Excedido'; + mensagem = `O funcionário excedeu o limite de saldo positivo configurado.`; + } else if ( + config.limiteSaldoNegativoMinutos && + Math.abs(bancoMensal.saldoFinalMinutos) > config.limiteSaldoNegativoMinutos + ) { + deveEnviar = true; + titulo = '⚠️ Alerta: Limite de Saldo Negativo Excedido'; + mensagem = `O funcionário excedeu o limite de saldo negativo configurado.`; + } + } + } + break; + } + + if (deveEnviar && (alertaConfig.enviarChat || alertaConfig.enviarEmail)) { + // Determinar destinatários: usar específicos se configurados, senão usar gestor padrão + const destinatariosChat = alertaConfig.destinatariosChat && alertaConfig.destinatariosChat.length > 0 + ? alertaConfig.destinatariosChat + : gestorId ? [gestorId] : []; + + const destinatariosEmail = alertaConfig.destinatariosEmail && alertaConfig.destinatariosEmail.length > 0 + ? alertaConfig.destinatariosEmail + : gestorId ? [gestorId] : []; + + // Enviar para destinatários de chat + if (alertaConfig.enviarChat && destinatariosChat.length > 0) { + for (const destinatarioId of destinatariosChat) { + await enviarAlertaChat(ctx, destinatarioId, titulo, mensagem); + } + } + + // Enviar para destinatários de email + if (alertaConfig.enviarEmail && destinatariosEmail.length > 0) { + for (const destinatarioId of destinatariosEmail) { + await enviarAlertaEmail(ctx, destinatarioId, titulo, mensagem); + } + } + + if (destinatariosChat.length > 0 || destinatariosEmail.length > 0) { + alertasEnviados++; + } + } + } + + return { alertasEnviados }; + } +}); + +/** + * Action interna para processar alertas de banco de horas (chamada por cron) + */ +export const processarAlertasBancoHoras = internalMutation({ + args: {}, + returns: v.object({ + funcionariosProcessados: v.number(), + alertasEnviados: v.number() + }), + handler: async (ctx) => { + // Buscar todos os funcionários ativos + const funcionarios = await ctx.db + .query('funcionarios') + .filter((q) => q.eq(q.field('desligamentoData'), undefined)) + .collect(); + + let totalAlertasEnviados = 0; + + for (const funcionario of funcionarios) { + const resultado = await ctx.runMutation(internal.pontos.detectarEEnviarAlertasBancoHoras, { + funcionarioId: funcionario._id + }); + totalAlertasEnviados += resultado.alertasEnviados; + } + + return { + funcionariosProcessados: funcionarios.length, + alertasEnviados: totalAlertasEnviados + }; + } +}); + +/** + * Inicializa alertas padrão do sistema (chamada uma vez) + */ +export const inicializarAlertasPadrao = mutation({ + args: {}, + returns: v.object({ + success: v.boolean(), + alertasCriados: v.number() + }), + handler: async (ctx) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // TODO: Verificar permissão de TI + + // Verificar se já existem alertas + const alertasExistentes = await ctx.db.query('alertasBancoHoras').collect(); + if (alertasExistentes.length > 0) { + return { success: true, alertasCriados: 0 }; + } + + // Criar alertas padrão + const tiposAlerta: Array<{ + tipoAlerta: 'saldo_negativo' | 'saldo_negativo_critico' | 'inconsistencia_detectada' | 'dias_sem_registro' | 'limite_saldo_excedido'; + periodicidade: 'diario' | 'semanal' | 'mensal'; + enviarEmail: boolean; + enviarChat: boolean; + limiteMinutos?: number; + }> = [ + { + tipoAlerta: 'saldo_negativo', + periodicidade: 'diario', + enviarEmail: true, + enviarChat: true, + limiteMinutos: 60 // 1 hora + }, + { + tipoAlerta: 'saldo_negativo_critico', + periodicidade: 'diario', + enviarEmail: true, + enviarChat: true + }, + { + tipoAlerta: 'inconsistencia_detectada', + periodicidade: 'diario', + enviarEmail: true, + enviarChat: true + }, + { + tipoAlerta: 'dias_sem_registro', + periodicidade: 'semanal', + enviarEmail: true, + enviarChat: true + }, + { + tipoAlerta: 'limite_saldo_excedido', + periodicidade: 'diario', + enviarEmail: true, + enviarChat: true + } + ]; + + let alertasCriados = 0; + for (const tipoAlerta of tiposAlerta) { + await ctx.db.insert('alertasBancoHoras', { + ...tipoAlerta, + ativo: true, + criadoPor: usuario._id, + criadoEm: Date.now() + }); + alertasCriados++; + } + + return { success: true, alertasCriados }; + } +}); diff --git a/packages/backend/convex/tables/ponto.ts b/packages/backend/convex/tables/ponto.ts index 3fb2992..d206f32 100644 --- a/packages/backend/convex/tables/ponto.ts +++ b/packages/backend/convex/tables/ponto.ts @@ -204,11 +204,26 @@ export const pontoTables = { horasTrabalhadas: v.number(), // Horas realmente trabalhadas (em minutos) saldoMinutos: v.number(), // Saldo do dia (positivo = horas extras, negativo = déficit) registrosPontoIds: v.array(v.id('registrosPonto')), // IDs dos registros do dia + // Novos campos para sistema avançado + ajustesIds: v.optional(v.array(v.id('ajustesBancoHoras'))), // IDs dos ajustes aplicados no dia + motivoAbono: v.optional(v.string()), // Motivo do abono (atestado, licença, ausência, etc.) + tipoDia: v.optional( + v.union( + v.literal('normal'), + v.literal('atestado'), + v.literal('licenca'), + v.literal('ausencia'), + v.literal('abonado'), + v.literal('descontado') + ) + ), // Tipo do dia + inconsistenciasIds: v.optional(v.array(v.id('inconsistenciasBancoHoras'))), // IDs de inconsistências detectadas calculadoEm: v.number() }) .index('by_funcionario_data', ['funcionarioId', 'data']) .index('by_funcionario', ['funcionarioId']) - .index('by_data', ['data']), + .index('by_data', ['data']) + .index('by_tipo_dia', ['tipoDia']), // Banco de Horas Mensal - Agregação mensal do banco de horas bancoHorasMensal: defineTable({ @@ -220,6 +235,11 @@ export const pontoTables = { diasTrabalhados: v.number(), // Quantidade de dias com registros no mês horasExtras: v.number(), // Total de minutos positivos do mês horasDeficit: v.number(), // Total de minutos negativos do mês (valor absoluto) + // Novos campos para sistema avançado + totalAjustes: v.optional(v.number()), // Total de ajustes aplicados no mês (em minutos) + totalAbonos: v.optional(v.number()), // Total de abonos no mês (em minutos) + totalDescontos: v.optional(v.number()), // Total de descontos no mês (em minutos) + inconsistenciasResolvidas: v.optional(v.number()), // Quantidade de inconsistências resolvidas calculadoEm: v.number(), atualizadoEm: v.number() }) @@ -279,5 +299,119 @@ export const pontoTables = { .index('by_gestor', ['gestorId']) .index('by_ativo', ['ativo']) .index('by_data_inicio', ['dataInicio']) - .index('by_data_fim', ['dataFim']) + .index('by_data_fim', ['dataFim']), + + // Configuração de Banco de Horas - Configurações gerais do sistema + configuracaoBancoHoras: defineTable({ + // Limites de saldo + limiteSaldoPositivoMinutos: v.optional(v.number()), // Limite máximo de saldo positivo (em minutos) + limiteSaldoNegativoMinutos: v.optional(v.number()), // Limite máximo de saldo negativo (em minutos) + // Regras de cálculo + considerarAjustesAutomaticos: v.optional(v.boolean()), // Se deve considerar ajustes automáticos (atestados, licenças, ausências) + // Periodicidade de verificação + periodicidadeVerificacao: v.optional( + v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal')) + ), + // Metadados + atualizadoPor: v.id('usuarios'), + atualizadoEm: v.number() + }).index('by_ativo', ['atualizadoEm']), + + // Alertas de Banco de Horas - Configuração de alertas por tipo + alertasBancoHoras: defineTable({ + tipoAlerta: v.union( + v.literal('saldo_negativo'), + v.literal('saldo_negativo_critico'), + v.literal('inconsistencia_detectada'), + v.literal('dias_sem_registro'), + v.literal('limite_saldo_excedido') + ), + // Periodicidade + periodicidade: v.union(v.literal('diario'), v.literal('semanal'), v.literal('mensal')), + // Canais de envio + enviarEmail: v.boolean(), + enviarChat: v.boolean(), + // Destinatários específicos (opcional - se vazio, envia para gestor padrão) + destinatariosEmail: v.optional(v.array(v.id('usuarios'))), // IDs de usuários que receberão email + destinatariosChat: v.optional(v.array(v.id('usuarios'))), // IDs de usuários que receberão chat + // Thresholds e limites + threshold: v.optional(v.number()), // Valor limite para disparar alerta + limiteMinutos: v.optional(v.number()), // Limite em minutos (para saldo negativo) + // Status + ativo: v.boolean(), + // Metadados + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoPor: v.optional(v.id('usuarios')), + atualizadoEm: v.optional(v.number()) + }) + .index('by_tipo', ['tipoAlerta']) + .index('by_ativo', ['ativo']) + .index('by_tipo_ativo', ['tipoAlerta', 'ativo']), + + // Ajustes de Banco de Horas - Registro de ajustes manuais e automáticos + ajustesBancoHoras: defineTable({ + funcionarioId: v.id('funcionarios'), + tipo: v.union(v.literal('abonar'), v.literal('descontar'), v.literal('compensar')), + // Motivo vinculado + motivoTipo: v.optional( + v.union( + v.literal('atestado'), + v.literal('licenca'), + v.literal('ausencia'), + v.literal('manual') + ) + ), + motivoId: v.optional(v.string()), // ID do atestado, licença, ausência ou null para manual + motivoDescricao: v.optional(v.string()), // Descrição do motivo + // Valor do ajuste + valorMinutos: v.number(), // Valor em minutos (positivo para abonar, negativo para descontar) + // Data de aplicação + dataAplicacao: v.string(), // YYYY-MM-DD + // Gestor responsável (null se automático) + gestorId: v.optional(v.id('usuarios')), + // Observações + observacoes: v.optional(v.string()), + // Status + aplicado: v.boolean(), // Se já foi aplicado ao banco de horas + // Metadados + criadoEm: v.number(), + aplicadoEm: v.optional(v.number()) + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_data_aplicacao', ['dataAplicacao']) + .index('by_funcionario_data', ['funcionarioId', 'dataAplicacao']) + .index('by_tipo', ['tipo']) + .index('by_aplicado', ['aplicado']) + .index('by_gestor', ['gestorId']), + + // Inconsistências de Banco de Horas - Registro de inconsistências detectadas + inconsistenciasBancoHoras: defineTable({ + funcionarioId: v.id('funcionarios'), + tipo: v.union( + v.literal('ponto_com_atestado'), + v.literal('ponto_com_licenca'), + v.literal('ponto_com_ausencia'), + v.literal('registro_duplicado'), + v.literal('sequencia_invalida'), + v.literal('saldo_inconsistente') + ), + descricao: v.string(), // Descrição detalhada da inconsistência + dataDetectada: v.string(), // YYYY-MM-DD + dataInconsistencia: v.string(), // YYYY-MM-DD (data do dia com inconsistência) + // Status + status: v.union(v.literal('pendente'), v.literal('resolvida'), v.literal('ignorada')), + // Resolução + resolucao: v.optional(v.string()), // Descrição da resolução + resolvidoPor: v.optional(v.id('usuarios')), // Usuário que resolveu + resolvidoEm: v.optional(v.number()), // Timestamp da resolução + // Metadados + criadoEm: v.number() + }) + .index('by_funcionario', ['funcionarioId']) + .index('by_status', ['status']) + .index('by_funcionario_status', ['funcionarioId', 'status']) + .index('by_data_detectada', ['dataDetectada']) + .index('by_tipo', ['tipo']) + .index('by_data_inconsistencia', ['dataInconsistencia']) };