diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..34cff05 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = tab +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = true \ No newline at end of file diff --git a/RELATORIO_TESTES.md b/RELATORIO_TESTES.md deleted file mode 100644 index 3780090..0000000 --- a/RELATORIO_TESTES.md +++ /dev/null @@ -1,186 +0,0 @@ -# Relatório de Testes - Sistema de Central de Chamados - -**Data:** 16 de novembro de 2025 -**Testador:** Sistema Automatizado -**Página Testada:** `/ti/central-chamados` - -## Resumo Executivo - -Foram realizados testes completos na página de Central de Chamados do sistema SGSE. A maioria das funcionalidades está funcionando corretamente, mas foram identificados alguns problemas que precisam ser corrigidos. - -## Testes Realizados - -### ✅ Testes Bem-Sucedidos - -1. **Login no Sistema** - - Status: ✅ PASSOU - - Usuário logado: Deyvison (dfw@poli.br) - -2. **Visualização de SLAs Configurados** - - Status: ✅ PASSOU - - Tabela de SLAs exibe 7 SLAs ativos corretamente - - Resumo mostra: 4 Baixa, 2 Média, 1 Alta/Crítica - - Detalhes completos (tempos, prioridades) são exibidos corretamente - -3. **Cards de Prioridade** - - Status: ✅ PASSOU - - Cards mostram corretamente "Configurado" ou "Não configurado" - - Botão "Configurar" funciona corretamente - - Detalhes dos SLAs configurados são exibidos nos cards - -4. **Criação de SLA** - - Status: ✅ PASSOU - - SLA criado com sucesso para prioridade "Alta" - - Formulário preenche corretamente quando clica em "Configurar" - - Tabela atualiza automaticamente após criação - - Card de prioridade atualiza para "Configurado" - -5. **Edição de SLA** - - Status: ✅ PASSOU - - Botão "Editar" abre formulário com dados corretos - - Atualização funciona corretamente - -6. **Lista de Chamados** - - Status: ✅ PASSOU - - 4 chamados sendo exibidos corretamente - - Filtros funcionando (status, responsável, setor) - - Detalhes do chamado são exibidos ao selecionar - -7. **Atribuição de Responsável** - - Status: ✅ PASSOU - - Dropdown mostra 2 usuários TI: Deyvison e Suporte_TI - - Formulário está funcional - -8. **Prorrogação de Prazo** - - Status: ✅ PASSOU - - Dropdown de tickets carrega corretamente (4 tickets) - - Formulário permite selecionar tipo de prazo e horas - - Botão habilita quando todos os campos estão preenchidos - -### ⚠️ Problemas Identificados - -#### 1. Templates de Email - Listagem Após Criação - -- **Status:** ⚠️ PROBLEMA -- **Descrição:** Templates são criados com sucesso (mensagem "Templates padrão criados com sucesso" aparece), mas não são listados na interface após criação -- **Ação Realizada:** Botão "Criar templates padrão" foi clicado e retornou sucesso -- **Comportamento Esperado:** Templates deveriam aparecer em uma lista após criação -- **Comportamento Atual:** Seção continua mostrando "Nenhum template encontrado" -- **Severidade:** MÉDIA -- **Impacto:** Usuários não conseguem visualizar/editar templates de email após criação -- **Possível Causa:** Query de templates pode não estar sendo atualizada após criação, ou filtro pode estar excluindo templates de chamados - -#### 2. Warning no Console - Token de Autenticação - -- **Status:** ⚠️ AVISO (Não crítico) -- **Descrição:** `⚠️ [useConvexWithAuth] Token não disponível` aparece no console durante carregamento inicial -- **Severidade:** BAIXA -- **Impacto:** Não afeta funcionalidade (autenticação funciona corretamente após carregamento) -- **Observação:** Parece ser um problema de timing durante inicialização da página - -#### 3. Warning no Console - Formato de Query - -- **Status:** ⚠️ AVISO (Não crítico) -- **Descrição:** `🔍 [usuariosTI] Formato inesperado: object {data: undefined, isLoading: undefined, error: undefined, isStale: undefined}` aparece no console -- **Severidade:** BAIXA -- **Impacto:** Não afeta funcionalidade (usuários são carregados corretamente - 2 usuários TI encontrados) -- **Observação:** Indica possível inconsistência no formato de retorno da query durante carregamento inicial - -## Detalhes dos Testes - -### Teste de Criação de SLA - -- **Prioridade Testada:** Alta -- **Valores Inseridos:** - - Nome: "SLA - Alta - Teste" - - Tempo de Resposta: 2h - - Tempo de Conclusão: 8h - - Auto-encerramento: 24h - - Alerta: 2h antes -- **Resultado:** ✅ SLA criado e exibido na tabela e no card - -### Teste de Edição de SLA - -- **SLA Editado:** Prioridade Baixa -- **Alterações:** - - Nome: "SLA Baixa - Editado em Teste" - - Tempo de Resposta: 6h -- **Resultado:** ✅ Atualização bem-sucedida - -### Teste de Prorrogação - -- **Ticket Selecionado:** SGSE-202511-3750 -- **Prazo:** Conclusão -- **Horas Adicionais:** 24h -- **Motivo:** "Teste de prorrogação de prazo - necessário mais tempo para análise" -- **Resultado:** ✅ Formulário preenchido corretamente, botão habilitado - -## Lista de Erros Encontrados - -### Erros Críticos - -- **Nenhum erro crítico encontrado** - -### Erros de Funcionalidade - -1. **Templates de Email não aparecem após criação** - - Localização: Seção "Templates de Email - Chamados" - - Ação necessária: Verificar query de templates e atualização reativa após criação - -### Avisos (Warnings) - -1. **Token de autenticação não disponível durante carregamento inicial** - - Localização: Console do navegador - - Ação necessária: Melhorar timing de inicialização de autenticação - -2. **Formato inesperado de query durante carregamento** - - Localização: Console do navegador (usuariosTI) - - Ação necessária: Verificar formato de retorno de useQuery do convex-svelte - -## Recomendações - -### Prioridade ALTA - -1. **Corrigir listagem de templates de email após criação** - - Verificar se a query `templatesChamados` está sendo atualizada após criação - - Verificar se o filtro de templates está correto (deve incluir templates de chamados) - - Adicionar refresh automático após criação de templates - -### Prioridade MÉDIA - -2. **Investigar e corrigir warnings no console** - - Melhorar timing de autenticação para evitar warning inicial - - Padronizar formato de retorno de queries do convex-svelte - -### Prioridade BAIXA - -3. **Melhorar logs de debug** - - Reduzir verbosidade de logs informativos - - Manter apenas logs de erro e warnings importantes - -## Conclusão - -O sistema está **funcionalmente operacional**, com a maioria das funcionalidades testadas funcionando corretamente: - -✅ **Funcionalidades Testadas e Funcionando:** - -- Login e autenticação -- Visualização de SLAs (tabela e cards) -- Criação de SLAs -- Edição de SLAs -- Lista de chamados -- Atribuição de responsável -- Prorrogação de prazo (formulário funcional) -- Criação de templates (backend funciona, frontend não atualiza) - -⚠️ **Problemas Identificados:** - -- Templates não aparecem na lista após criação (problema de atualização reativa) -- Warnings no console (não afetam funcionalidade) - -**Status Geral:** ✅ **OPERACIONAL COM PEQUENOS AJUSTES NECESSÁRIOS** - -**Próximos Passos:** - -1. Corrigir atualização reativa de templates após criação -2. Investigar e resolver warnings do console (opcional, não crítico) diff --git a/apps/web/CONFIGURACAO_ENV.md b/apps/web/CONFIGURACAO_ENV.md new file mode 100644 index 0000000..91f3551 --- /dev/null +++ b/apps/web/CONFIGURACAO_ENV.md @@ -0,0 +1,29 @@ +# ⚙️ Configuração de Variáveis de Ambiente + +## 📁 Arquivo .env + +Crie um arquivo `.env` na pasta `apps/web/` com as seguintes variáveis: + +```env +# Google Maps API Key (opcional) +# Obtenha sua chave em: https://console.cloud.google.com/ +# Ative a "Geocoding API" para buscar coordenadas por endereço +# Deixe vazio para usar OpenStreetMap (gratuito, sem necessidade de chave) +VITE_GOOGLE_MAPS_API_KEY= + +# VAPID Public Key para Push Notifications (opcional) +VITE_VAPID_PUBLIC_KEY= +``` + +## 📖 Documentação Completa + +Para instruções detalhadas sobre como obter e configurar a Google Maps API Key, consulte: + +📄 **[GOOGLE_MAPS_SETUP.md](./GOOGLE_MAPS_SETUP.md)** + +## ⚠️ Importante + +- O arquivo `.env` não deve ser commitado no Git (já está no .gitignore) +- Variáveis de ambiente começam com `VITE_` para serem acessíveis no frontend +- Reinicie o servidor de desenvolvimento após alterar o arquivo `.env` + diff --git a/apps/web/GOOGLE_MAPS_SETUP.md b/apps/web/GOOGLE_MAPS_SETUP.md new file mode 100644 index 0000000..7ccfb87 --- /dev/null +++ b/apps/web/GOOGLE_MAPS_SETUP.md @@ -0,0 +1,174 @@ +# 📍 Configuração do Google Maps API para Busca de Coordenadas + +Este guia explica como configurar a API do Google Maps para obter coordenadas GPS de forma automática e precisa no sistema de Endereços de Marcação. + +## 🎯 Por que usar Google Maps? + +- ✅ **Maior Precisão**: Resultados mais exatos para endereços brasileiros +- ✅ **Melhor Cobertura**: Banco de dados mais completo e atualizado +- ✅ **Geocoding Avançado**: Entende melhor endereços incompletos ou parciais + +> **Nota**: O sistema funciona perfeitamente sem a API key do Google Maps, usando OpenStreetMap (gratuito). A configuração do Google Maps é opcional. + +--- + +## 📋 Passo a Passo + +### 1. Criar Projeto no Google Cloud Platform + +1. Acesse [Google Cloud Console](https://console.cloud.google.com/) +2. Clique em **"Criar Projeto"** ou selecione um projeto existente +3. Preencha o nome do projeto (ex: "SGSE-App") +4. Clique em **"Criar"** + +### 2. Ativar a Geocoding API + +1. No menu lateral, vá em **"APIs e Serviços"** > **"Biblioteca"** +2. Procure por **"Geocoding API"** +3. Clique no resultado e depois em **"Ativar"** +4. Aguarde alguns segundos para a ativação + +### 3. Criar Chave de API + +1. Ainda em **"APIs e Serviços"**, vá em **"Credenciais"** +2. Clique em **"Criar Credenciais"** > **"Chave de API"** +3. Copie a chave gerada (você precisará dela depois) + +### 4. Configurar Restrições de Segurança (Recomendado) + +Para proteger sua chave de API: + +1. Clique na chave criada para editá-la +2. Em **"Restrições de API"**: + - Selecione **"Restringir chave"** + - Escolha **"Geocoding API"** +3. Em **"Restrições de aplicativo"**: + - Para desenvolvimento local: escolha **"Referenciadores de sites HTTP"** + - Adicione: `http://localhost:*` e `http://127.0.0.1:*` + - Para produção: adicione o domínio do seu site +4. Clique em **"Salvar"** + +### 5. Configurar no Projeto + +1. No diretório `apps/web/`, copie o arquivo de exemplo: + ```bash + cp .env.example .env + ``` + +2. Abra o arquivo `.env` e adicione sua chave: + ```env + VITE_GOOGLE_MAPS_API_KEY=sua_chave_aqui + ``` + +3. Reinicie o servidor de desenvolvimento: + ```bash + npm run dev + ``` + +### 6. Verificar se está funcionando + +1. Acesse a página de **Endereços de Marcação** (`/ti/configuracoes-ponto/enderecos`) +2. Clique em **"Novo Endereço"** +3. Preencha um endereço e clique em **"Buscar GPS"** +4. Se configurado corretamente, verá a mensagem: *"Coordenadas encontradas via Google Maps!"* + +--- + +## 💰 Custos + +### Google Maps Geocoding API + +- **$5.00 por 1.000 requisições** (primeiros 40.000 são gratuitos por mês) +- **$0.005 por requisição** após os 40.000 gratuitos + +> 💡 Para a maioria dos casos de uso, os 40.000 gratuitos são suficientes! + +### OpenStreetMap (Fallback) + +- **100% Gratuito** e ilimitado +- Sem necessidade de configuração +- Precisão levemente menor, mas ainda muito boa + +--- + +## 🔄 Como funciona o sistema + +O sistema foi projetado para usar uma estratégia de **fallback inteligente**: + +1. **Primeiro**: Tenta buscar via Google Maps (se API key configurada) +2. **Se falhar ou não tiver API key**: Usa automaticamente OpenStreetMap +3. **Feedback**: Informa qual serviço foi usado na mensagem de sucesso + +Isso garante que o sistema sempre funcione, mesmo sem a API key do Google Maps. + +--- + +## 🔒 Segurança + +### ⚠️ Importante + +- **Nunca** commite o arquivo `.env` no Git (já está no .gitignore) +- **Nunca** compartilhe sua chave de API publicamente +- Configure **restrições de API** no Google Cloud Console +- Para produção, use variáveis de ambiente seguras no seu provedor de hospedagem + +### Configuração em Produção + +Para ambientes de produção (Vercel, Netlify, etc.): + +1. Acesse as configurações do projeto no seu provedor +2. Vá em **"Environment Variables"** ou **"Variáveis de Ambiente"** +3. Adicione: `VITE_GOOGLE_MAPS_API_KEY` com o valor da sua chave +4. Faça o deploy novamente + +--- + +## ❓ Solução de Problemas + +### A busca não está usando Google Maps + +- Verifique se a variável `VITE_GOOGLE_MAPS_API_KEY` está no arquivo `.env` +- Reinicie o servidor de desenvolvimento +- Verifique no console do navegador se há erros + +### Erro: "This API project is not authorized to use this API" + +- Verifique se a **Geocoding API** está ativada no projeto +- Aguarde alguns minutos após a ativação (pode levar até 5 minutos) + +### Erro: "API key not valid" + +- Verifique se copiou a chave corretamente +- Verifique se as restrições de API permitem o uso da Geocoding API +- Verifique se as restrições de aplicativo permitem seu domínio/endereço + +### Mensagem: "Coordenadas encontradas via OpenStreetMap" + +- Isso é normal se: + - Não há API key configurada + - A API key não é válida + - O Google Maps falhou na busca +- O sistema continua funcionando normalmente com OpenStreetMap + +--- + +## 📚 Recursos Úteis + +- [Google Cloud Console](https://console.cloud.google.com/) +- [Documentação Geocoding API](https://developers.google.com/maps/documentation/geocoding) +- [Preços Google Maps](https://developers.google.com/maps/billing-and-pricing/pricing) +- [OpenStreetMap Nominatim](https://nominatim.org/) + +--- + +## ✅ Resumo + +1. ✅ Crie projeto no Google Cloud +2. ✅ Ative Geocoding API +3. ✅ Crie chave de API +4. ✅ Configure restrições (recomendado) +5. ✅ Adicione `VITE_GOOGLE_MAPS_API_KEY` no `.env` +6. ✅ Reinicie o servidor + +**Pronto!** O sistema agora usará Google Maps para busca de coordenadas com maior precisão. + diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index fe64cde..bc99b9f 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -9,7 +9,7 @@ import NotificationBell from '$lib/components/chat/NotificationBell.svelte'; import ChatWidget from '$lib/components/chat/ChatWidget.svelte'; import PresenceManager from '$lib/components/chat/PresenceManager.svelte'; - import { getAvatarUrl } from '$lib/utils/avatarGenerator'; + import { Menu, User, Home, UserPlus, XCircle, LogIn, Tag, Plus, Check } from 'lucide-svelte'; import { authClient } from '$lib/auth'; import { resolve } from '$app/paths'; @@ -33,8 +33,8 @@ return currentUser.data.avatar; } - // Fallback: gerar avatar baseado no nome - return getAvatarUrl(currentUser.data.nome); + // Fallback: retornar null para usar o ícone User do Lucide + return null; }); // Função para gerar classes do menu ativo @@ -328,8 +328,9 @@ >Contato - SuporteSuporte Dashboard - {#each setores as s} + {#each setores as s (s.link)} {@const isActive = currentPath.startsWith(s.link)}
  • -
    -
    +
    +
    -
    +
    - Logo SGSE + Logo SGSE

    Login

    @@ -441,7 +434,7 @@ {#if erroLogin}
    {erroLogin} @@ -453,16 +446,14 @@
    -
    +
    + + +

    + {formatarHoraPonto(registro.hora, registro.minuto)} +

    + + + {#if config} + {@const horarioEsperado = registro.tipo === 'entrada' ? config.horarioEntrada : config.horarioRetornoAlmoco} + {@const [horaEsperada, minutoEsperado] = horarioEsperado.split(':').map(Number)} + {@const minutosEsperados = horaEsperada * 60 + minutoEsperado} + {@const minutosRegistrados = registro.hora * 60 + registro.minuto} + {@const diferenca = minutosRegistrados - minutosEsperados} + {@const diferencaAbs = Math.abs(diferenca)} + {@const diferencaTexto = diferencaAbs >= 60 + ? `${Math.floor(diferencaAbs / 60)}h ${diferencaAbs % 60}min` + : `${diferencaAbs}min`} + +
    + Esperado: + {horarioEsperado} + {#if diferencaAbs > 0} + + {diferenca > 0 ? '+' : '-'}{diferencaTexto} + + {/if} +
    + {/if} + + {#if registro.justificativa} +
    +

    Justificativa:

    +

    {registro.justificativa}

    +
    + {/if} + + +
    -
    + {/each} + + + {#if config} + {#each [ + { tipo: 'entrada', horario: config.horarioEntrada, label: config.nomeEntrada || 'Entrada 1' }, + { tipo: 'retorno_almoco', horario: config.horarioRetornoAlmoco, label: config.nomeRetornoAlmoco || 'Entrada 2' } + ] as horarioEsperado} + {#if !registrosOrdenados.find(r => r.tipo === horarioEsperado.tipo)} +
    +
    +
    +
    +

    {horarioEsperado.label} (não registrado)

    +

    {horarioEsperado.horario}

    +
    +
    +
    + {/if} + {/each} + {/if}
    - {/each} + + +
    +
    +

    + + Saídas +

    +
    + + {#each registrosOrdenados.filter(r => r.tipo === 'saida_almoco' || r.tipo === 'saida') as registro (registro._id)} +
    + +
    + + +
    +
    + +
    + + {config + ? getTipoRegistroLabel(registro.tipo, { + nomeSaidaAlmoco: config.nomeSaidaAlmoco, + nomeSaida: config.nomeSaida, + }) + : getTipoRegistroLabel(registro.tipo)} + + {#if registro.dentroDoPrazo} + + {:else} + + {/if} +
    + + +

    + {formatarHoraPonto(registro.hora, registro.minuto)} +

    + + + {#if config} + {@const horarioEsperado = registro.tipo === 'saida_almoco' ? config.horarioSaidaAlmoco : config.horarioSaida} + {@const [horaEsperada, minutoEsperado] = horarioEsperado.split(':').map(Number)} + {@const minutosEsperados = horaEsperada * 60 + minutoEsperado} + {@const minutosRegistrados = registro.hora * 60 + registro.minuto} + {@const diferenca = minutosRegistrados - minutosEsperados} + {@const diferencaAbs = Math.abs(diferenca)} + {@const diferencaTexto = diferencaAbs >= 60 + ? `${Math.floor(diferencaAbs / 60)}h ${diferencaAbs % 60}min` + : `${diferencaAbs}min`} + +
    + {#if diferencaAbs > 0} + + {diferenca > 0 ? '+' : '-'}{diferencaTexto} + + {/if} + {horarioEsperado} + Esperado: +
    + {/if} + + {#if registro.justificativa} +
    +

    Justificativa:

    +

    {registro.justificativa}

    +
    + {/if} + + +
    +
    +
    + {/each} + + + {#if config} + {#each [ + { tipo: 'saida_almoco', horario: config.horarioSaidaAlmoco, label: config.nomeSaidaAlmoco || 'Saída 1' }, + { tipo: 'saida', horario: config.horarioSaida, label: config.nomeSaida || 'Saída 2' } + ] as horarioEsperado} + {#if !registrosOrdenados.find(r => r.tipo === horarioEsperado.tipo)} +
    +
    +
    +
    +

    {horarioEsperado.label} (não registrado)

    +

    {horarioEsperado.horario}

    +
    +
    +
    + {/if} + {/each} + {/if} +
    +
    diff --git a/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte index aaf2e5b..1bb6e23 100644 --- a/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte +++ b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte @@ -17,36 +17,45 @@ async function atualizarTempo() { try { const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {}); + const gmtOffset = config.gmtOffset ?? 0; + + let timestampBase: number; if (config.usarServidorExterno) { try { const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {}); if (resultado.sucesso && resultado.timestamp) { - tempoAtual = new Date(resultado.timestamp); + timestampBase = resultado.timestamp; sincronizado = true; usandoServidorExterno = resultado.usandoServidorExterno || false; offsetSegundos = resultado.offsetSegundos || 0; erro = null; + } else { + throw new Error('Falha ao sincronizar'); } } catch (error) { console.warn('Erro ao sincronizar:', error); if (config.fallbackParaPC) { - tempoAtual = new Date(obterTempoPC()); + timestampBase = obterTempoPC(); sincronizado = false; usandoServidorExterno = false; erro = 'Usando relógio do PC (falha na sincronização)'; } else { - erro = 'Falha ao sincronizar tempo'; + throw error; } } } else { // Usar tempo do servidor Convex - const tempoServidor = await obterTempoServidor(client); - tempoAtual = new Date(tempoServidor); + timestampBase = await obterTempoServidor(client); sincronizado = true; usandoServidorExterno = false; erro = null; } + + // Aplicar GMT offset ao timestamp + // O timestamp está em UTC, adicionar o offset em horas + const timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000); + tempoAtual = new Date(timestampAjustado); } catch (error) { console.error('Erro ao obter tempo:', error); tempoAtual = new Date(obterTempoPC()); diff --git a/apps/web/src/lib/utils/avatarGenerator.ts b/apps/web/src/lib/utils/avatarGenerator.ts deleted file mode 100644 index ea9bda3..0000000 --- a/apps/web/src/lib/utils/avatarGenerator.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Mapa de seeds para os 32 avatares -const avatarSeeds: Record = { - // Masculinos (16) - "avatar-m-1": "John", - "avatar-m-2": "Peter", - "avatar-m-3": "Michael", - "avatar-m-4": "David", - "avatar-m-5": "James", - "avatar-m-6": "Robert", - "avatar-m-7": "William", - "avatar-m-8": "Joseph", - "avatar-m-9": "Thomas", - "avatar-m-10": "Charles", - "avatar-m-11": "Daniel", - "avatar-m-12": "Matthew", - "avatar-m-13": "Anthony", - "avatar-m-14": "Mark", - "avatar-m-15": "Donald", - "avatar-m-16": "Steven", - // Femininos (16) - "avatar-f-1": "Maria", - "avatar-f-2": "Ana", - "avatar-f-3": "Patricia", - "avatar-f-4": "Jennifer", - "avatar-f-5": "Linda", - "avatar-f-6": "Barbara", - "avatar-f-7": "Elizabeth", - "avatar-f-8": "Jessica", - "avatar-f-9": "Sarah", - "avatar-f-10": "Karen", - "avatar-f-11": "Nancy", - "avatar-f-12": "Betty", - "avatar-f-13": "Helen", - "avatar-f-14": "Sandra", - "avatar-f-15": "Ashley", - "avatar-f-16": "Kimberly", -}; - -/** - * Gera URL do avatar usando API DiceBear com parâmetros simples - */ -export function getAvatarUrl(avatarId: string): string { - const seed = avatarSeeds[avatarId] || avatarId || "default"; - - // Usar avataarstyle do DiceBear com parâmetros mínimos - // API v7 suporta apenas parâmetros específicos - return `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(seed)}`; -} - -/** - * Lista todos os IDs de avatares disponíveis - */ -export function getAllAvatarIds(): string[] { - return Object.keys(avatarSeeds); -} - -/** - * Verifica se um avatarId é válido - */ -export function isValidAvatarId(avatarId: string): boolean { - return avatarId in avatarSeeds; -} - diff --git a/apps/web/src/lib/utils/avatars.ts b/apps/web/src/lib/utils/avatars.ts deleted file mode 100644 index dcb8438..0000000 --- a/apps/web/src/lib/utils/avatars.ts +++ /dev/null @@ -1,283 +0,0 @@ -// Galeria de avatares inspirados em artistas do cinema -// Usando DiceBear API com estilos variados para aparência cinematográfica - -export interface Avatar { - id: string; - name: string; - url: string; - seed: string; - style: string; -} - -// Avatares inspirados em artistas do cinema (30 avatares estilizados) -const cinemaArtistsAvatars = [ - // 15 Masculinos - Inspirados em grandes atores - { - id: 'avatar-male-1', - name: 'Leonardo DiCaprio', - seed: 'Leonardo', - style: 'adventurer', - bgColor: 'C5CAE9', - }, - { - id: 'avatar-male-2', - name: 'Brad Pitt', - seed: 'Bradley', - style: 'adventurer', - bgColor: 'B2DFDB', - }, - { - id: 'avatar-male-3', - name: 'Tom Hanks', - seed: 'Thomas', - style: 'adventurer-neutral', - bgColor: 'DCEDC8', - }, - { - id: 'avatar-male-4', - name: 'Morgan Freeman', - seed: 'Morgan', - style: 'adventurer', - bgColor: 'F0F4C3', - }, - { - id: 'avatar-male-5', - name: 'Robert De Niro', - seed: 'Robert', - style: 'adventurer-neutral', - bgColor: 'E0E0E0', - }, - { - id: 'avatar-male-6', - name: 'Al Pacino', - seed: 'Alfredo', - style: 'adventurer', - bgColor: 'FFCCBC', - }, - { - id: 'avatar-male-7', - name: 'Johnny Depp', - seed: 'John', - style: 'adventurer', - bgColor: 'D1C4E9', - }, - { - id: 'avatar-male-8', - name: 'Denzel Washington', - seed: 'Denzel', - style: 'adventurer-neutral', - bgColor: 'B3E5FC', - }, - { - id: 'avatar-male-9', - name: 'Will Smith', - seed: 'Willard', - style: 'adventurer', - bgColor: 'FFF9C4', - }, - { - id: 'avatar-male-10', - name: 'Tom Cruise', - seed: 'TomC', - style: 'adventurer-neutral', - bgColor: 'CFD8DC', - }, - { - id: 'avatar-male-11', - name: 'Samuel L Jackson', - seed: 'Samuel', - style: 'adventurer', - bgColor: 'F8BBD0', - }, - { - id: 'avatar-male-12', - name: 'Harrison Ford', - seed: 'Harrison', - style: 'adventurer-neutral', - bgColor: 'C8E6C9', - }, - { - id: 'avatar-male-13', - name: 'Keanu Reeves', - seed: 'Keanu', - style: 'adventurer', - bgColor: 'BBDEFB', - }, - { - id: 'avatar-male-14', - name: 'Matt Damon', - seed: 'Matthew', - style: 'adventurer-neutral', - bgColor: 'FFE0B2', - }, - { - id: 'avatar-male-15', - name: 'Christian Bale', - seed: 'Christian', - style: 'adventurer', - bgColor: 'E1BEE7', - }, - // 15 Femininos - Inspiradas em grandes atrizes - { - id: 'avatar-female-1', - name: 'Meryl Streep', - seed: 'Meryl', - style: 'lorelei', - bgColor: 'F8BBD0', - }, - { - id: 'avatar-female-2', - name: 'Scarlett Johansson', - seed: 'Scarlett', - style: 'lorelei', - bgColor: 'FFCCBC', - }, - { - id: 'avatar-female-3', - name: 'Jennifer Lawrence', - seed: 'Jennifer', - style: 'lorelei-neutral', - bgColor: 'E1BEE7', - }, - { - id: 'avatar-female-4', - name: 'Angelina Jolie', - seed: 'Angelina', - style: 'lorelei', - bgColor: 'C5CAE9', - }, - { - id: 'avatar-female-5', - name: 'Cate Blanchett', - seed: 'Catherine', - style: 'lorelei-neutral', - bgColor: 'B2DFDB', - }, - { - id: 'avatar-female-6', - name: 'Nicole Kidman', - seed: 'Nicole', - style: 'lorelei', - bgColor: 'DCEDC8', - }, - { - id: 'avatar-female-7', - name: 'Julia Roberts', - seed: 'Julia', - style: 'lorelei-neutral', - bgColor: 'FFF9C4', - }, - { - id: 'avatar-female-8', - name: 'Emma Stone', - seed: 'Emma', - style: 'lorelei', - bgColor: 'CFD8DC', - }, - { - id: 'avatar-female-9', - name: 'Natalie Portman', - seed: 'Natalie', - style: 'lorelei-neutral', - bgColor: 'F0F4C3', - }, - { - id: 'avatar-female-10', - name: 'Charlize Theron', - seed: 'Charlize', - style: 'lorelei', - bgColor: 'E0E0E0', - }, - { - id: 'avatar-female-11', - name: 'Kate Winslet', - seed: 'Kate', - style: 'lorelei-neutral', - bgColor: 'D1C4E9', - }, - { - id: 'avatar-female-12', - name: 'Sandra Bullock', - seed: 'Sandra', - style: 'lorelei', - bgColor: 'B3E5FC', - }, - { - id: 'avatar-female-13', - name: 'Halle Berry', - seed: 'Halle', - style: 'lorelei-neutral', - bgColor: 'C8E6C9', - }, - { - id: 'avatar-female-14', - name: 'Anne Hathaway', - seed: 'Anne', - style: 'lorelei', - bgColor: 'BBDEFB', - }, - { - id: 'avatar-female-15', - name: 'Amy Adams', - seed: 'Amy', - style: 'lorelei-neutral', - bgColor: 'FFE0B2', - }, -]; - -/** - * Gera uma galeria de avatares inspirados em artistas do cinema - * Usa DiceBear API com estilos cinematográficos - * @param count Número de avatares a gerar (padrão: 30) - * @returns Array de objetos com id, name, url, seed e style - */ -export function generateAvatarGallery(count: number = 30): Avatar[] { - const avatars: Avatar[] = []; - - for (let i = 0; i < Math.min(count, cinemaArtistsAvatars.length); i++) { - const avatar = cinemaArtistsAvatars[i]; - - // URL do DiceBear com estilo cinematográfico - const url = `https://api.dicebear.com/7.x/${avatar.style}/svg?seed=${encodeURIComponent(avatar.seed)}&backgroundColor=${avatar.bgColor}&radius=50&size=200`; - - avatars.push({ - id: avatar.id, - name: avatar.name, - url, - seed: avatar.seed, - style: avatar.style, - }); - } - - return avatars; -} - -/** - * Obter URL do avatar por ID - * @param avatarId ID do avatar (ex: "avatar-male-1") - * @returns URL do avatar ou string vazia se não encontrado - */ -export function getAvatarUrl(avatarId: string): string { - const gallery = generateAvatarGallery(); - const avatar = gallery.find(a => a.id === avatarId); - return avatar?.url || ''; -} - -/** - * Gerar avatar aleatório da galeria - * @returns Avatar aleatório - */ -export function getRandomAvatar(): Avatar { - const gallery = generateAvatarGallery(); - const randomIndex = Math.floor(Math.random() * gallery.length); - return gallery[randomIndex]; -} - -/** - * Salvar avatar selecionado (retorna o ID para salvar no backend) - * @param avatarId ID do avatar selecionado - * @returns ID do avatar - */ -export function saveAvatarSelection(avatarId: string): string { - return avatarId; -} diff --git a/apps/web/src/lib/utils/deviceInfo.ts b/apps/web/src/lib/utils/deviceInfo.ts index f0f2c0f..ed3526a 100644 --- a/apps/web/src/lib/utils/deviceInfo.ts +++ b/apps/web/src/lib/utils/deviceInfo.ts @@ -15,6 +15,13 @@ export interface InformacoesDispositivo { latitude?: number; longitude?: number; precisao?: number; + altitude?: number | null; + altitudeAccuracy?: number | null; + heading?: number | null; + speed?: number | null; + confiabilidadeGPS?: number; // 0-1 + suspeitaSpoofing?: boolean; + motivoSuspeita?: string; endereco?: string; cidade?: string; estado?: string; @@ -230,12 +237,289 @@ function obterInformacoesMemoria(): string { } /** - * Obtém localização via GPS com múltiplas tentativas + * Calcula distância entre duas coordenadas (fórmula de Haversine) + * Retorna distância em metros + */ +function calcularDistancia( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const R = 6371000; // Raio da Terra em metros + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +/** + * Obtém timezone aproximado por coordenadas + */ +function obterTimezonePorCoordenadas(latitude: number, longitude: number): string { + // Pernambuco está em UTC-3 (America/Recife) + if (longitude >= -45 && longitude <= -30 && latitude >= -10 && latitude <= 5) { + return 'America/Recife'; // UTC-3 + } + + // Fallback: usar timezone do sistema + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return 'America/Recife'; // Default + } +} + +/** + * Captura uma única leitura de localização com todas as propriedades disponíveis + */ +async function capturarLocalizacaoUnica( + enableHighAccuracy: boolean = true, + timeout: number = 10000 +): Promise<{ + latitude?: number; + longitude?: number; + precisao?: number; + altitude?: number | null; + altitudeAccuracy?: number | null; + heading?: number | null; + speed?: number | null; + timestamp?: number; + confiabilidade: number; // 0-1 +}> { + return new Promise((resolve) => { + if (typeof navigator === 'undefined' || !navigator.geolocation) { + resolve({ confiabilidade: 0 }); + return; + } + + const timeoutId = setTimeout(() => { + resolve({ confiabilidade: 0 }); + }, timeout + 1000); + + navigator.geolocation.getCurrentPosition( + (position) => { + clearTimeout(timeoutId); + const coords = position.coords; + const { latitude, longitude, accuracy } = coords; + + // Validar coordenadas básicas + if ( + isNaN(latitude) || + isNaN(longitude) || + latitude === 0 || + longitude === 0 || + latitude < -90 || + latitude > 90 || + longitude < -180 || + longitude > 180 + ) { + resolve({ confiabilidade: 0 }); + return; + } + + // Calcular score de confiabilidade baseado em propriedades do GPS real + const sinaisGPSReal = { + temAltitude: coords.altitude !== null && coords.altitude !== 0, + temAltitudeAccuracy: coords.altitudeAccuracy !== null && coords.altitudeAccuracy > 0, + temHeading: coords.heading !== null && !isNaN(coords.heading), + temSpeed: coords.speed !== null && !isNaN(coords.speed), + precisaoBoa: accuracy < 20, // GPS real geralmente < 20m + precisaoMedia: accuracy >= 20 && accuracy < 100, + timestampPreciso: position.timestamp > 0 + }; + + // Calcular confiabilidade: cada sinal adiciona pontos + let pontos = 0; + const maxPontos = 7; + + if (sinaisGPSReal.temAltitude) pontos += 1; + if (sinaisGPSReal.temAltitudeAccuracy) pontos += 1; + if (sinaisGPSReal.temHeading) pontos += 0.5; + if (sinaisGPSReal.temSpeed) pontos += 0.5; + if (sinaisGPSReal.precisaoBoa) pontos += 2; + if (sinaisGPSReal.precisaoMedia) pontos += 1; + if (sinaisGPSReal.timestampPreciso) pontos += 1; + + const confiabilidade = Math.min(pontos / maxPontos, 1); + + resolve({ + latitude, + longitude, + precisao: accuracy, + altitude: coords.altitude ?? null, + altitudeAccuracy: coords.altitudeAccuracy ?? null, + heading: coords.heading ?? null, + speed: coords.speed ?? null, + timestamp: position.timestamp, + confiabilidade + }); + }, + (error) => { + clearTimeout(timeoutId); + console.warn('Erro ao obter localização:', error.code, error.message); + resolve({ confiabilidade: 0 }); + }, + { + enableHighAccuracy, + timeout, + maximumAge: 0 // Sempre obter nova leitura + } + ); + }); +} + +/** + * Obtém localização via GPS com múltiplas leituras para detectar spoofing + * Apps de spoofing geralmente retornam valores idênticos em todas as leituras + */ +async function obterLocalizacaoMultipla(): Promise<{ + latitude?: number; + longitude?: number; + precisao?: number; + altitude?: number | null; + altitudeAccuracy?: number | null; + heading?: number | null; + speed?: number | null; + confiabilidade: number; // 0-1 + suspeitaSpoofing: boolean; + motivoSuspeita?: string; +}> { + if (typeof navigator === 'undefined' || !navigator.geolocation) { + return { confiabilidade: 0, suspeitaSpoofing: true, motivoSuspeita: 'Geolocalização não suportada' }; + } + + // Capturar 3 leituras com intervalo de 2 segundos entre elas + const leituras: Array<{ + lat: number; + lon: number; + precisao: number; + altitude: number | null; + confiabilidade: number; + }> = []; + + for (let i = 0; i < 3; i++) { + const leitura = await capturarLocalizacaoUnica(true, 8000); + + if (leitura.latitude && leitura.longitude && leitura.confiabilidade > 0) { + leituras.push({ + lat: leitura.latitude, + lon: leitura.longitude, + precisao: leitura.precisao || 999, + altitude: leitura.altitude ?? null, + confiabilidade: leitura.confiabilidade + }); + } + + // Aguardar 2 segundos entre leituras (exceto na última) + if (i < 2) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + + if (leituras.length === 0) { + return { confiabilidade: 0, suspeitaSpoofing: true, motivoSuspeita: 'Não foi possível obter localização' }; + } + + // Se tivermos menos de 2 leituras, usar única leitura com baixa confiança + if (leituras.length < 2) { + const unica = leituras[0]; + return { + latitude: unica.lat, + longitude: unica.lon, + precisao: unica.precisao, + altitude: unica.altitude, + altitudeAccuracy: null, + heading: null, + speed: null, + confiabilidade: unica.confiabilidade * 0.5, // Reduzir confiança por ter apenas 1 leitura + suspeitaSpoofing: true, + motivoSuspeita: 'Apenas uma leitura obtida' + }; + } + + // Verificar se todas as leituras são idênticas (suspeito de spoofing) + const primeiraLeitura = leituras[0]; + const todasIguais = leituras.every( + (l) => + Math.abs(l.lat - primeiraLeitura.lat) < 0.00001 && // ~1 metro + Math.abs(l.lon - primeiraLeitura.lon) < 0.00001 + ); + + if (todasIguais && leituras.length === 3) { + // GPS real varia alguns metros, se todas são idênticas pode ser spoofing + return { + latitude: primeiraLeitura.lat, + longitude: primeiraLeitura.lon, + precisao: primeiraLeitura.precisao, + altitude: primeiraLeitura.altitude, + altitudeAccuracy: null, + heading: null, + speed: null, + confiabilidade: primeiraLeitura.confiabilidade * 0.4, // Reduzir drasticamente confiança + suspeitaSpoofing: true, + motivoSuspeita: 'Todas as leituras são idênticas (GPS real varia alguns metros)' + }; + } + + // Calcular média das leituras e variância + const mediaLat = leituras.reduce((sum, l) => sum + l.lat, 0) / leituras.length; + const mediaLon = leituras.reduce((sum, l) => sum + l.lon, 0) / leituras.length; + const mediaConfianca = leituras.reduce((sum, l) => sum + l.confiabilidade, 0) / leituras.length; + + // Calcular distância máxima entre leituras + let distanciaMaxima = 0; + for (let i = 0; i < leituras.length; i++) { + for (let j = i + 1; j < leituras.length; j++) { + const dist = calcularDistancia( + leituras[i].lat, + leituras[i].lon, + leituras[j].lat, + leituras[j].lon + ); + distanciaMaxima = Math.max(distanciaMaxima, dist); + } + } + + // Se distância máxima for muito grande (> 100m), pode indicar problemas + const suspeitoPorDistancia = distanciaMaxima > 100; + + return { + latitude: mediaLat, + longitude: mediaLon, + precisao: primeiraLeitura.precisao, + altitude: primeiraLeitura.altitude, + altitudeAccuracy: null, + heading: null, + speed: null, + confiabilidade: suspeitoPorDistancia ? mediaConfianca * 0.6 : mediaConfianca, + suspeitaSpoofing: suspeitoPorDistancia, + motivoSuspeita: suspeitoPorDistancia + ? `Variação muito grande entre leituras (${Math.round(distanciaMaxima)}m)` + : undefined + }; +} + +/** + * Obtém localização via GPS com múltiplas tentativas e validações anti-spoofing */ async function obterLocalizacao(): Promise<{ latitude?: number; longitude?: number; precisao?: number; + altitude?: number | null; + altitudeAccuracy?: number | null; + heading?: number | null; + speed?: number | null; + confiabilidadeGPS?: number; + suspeitaSpoofing?: boolean; + motivoSuspeita?: string; endereco?: string; cidade?: string; estado?: string; @@ -246,127 +530,95 @@ async function obterLocalizacao(): Promise<{ return {}; } - // Tentar múltiplas estratégias - const estrategias = [ - // Estratégia 1: Alta precisão (mais lento, mas mais preciso) - { - enableHighAccuracy: true, - timeout: 10000, - maximumAge: 0 - }, - // Estratégia 2: Precisão média (balanceado) - { - enableHighAccuracy: false, - timeout: 8000, - maximumAge: 30000 - }, - // Estratégia 3: Rápido (usa cache) - { - enableHighAccuracy: false, - timeout: 5000, - maximumAge: 60000 - } - ]; + // Usar múltiplas leituras para detectar spoofing + const localizacaoMultipla = await obterLocalizacaoMultipla(); - for (const options of estrategias) { - try { - const resultado = await new Promise<{ - latitude?: number; - longitude?: number; - precisao?: number; - endereco?: string; - cidade?: string; - estado?: string; - pais?: string; - }>((resolve) => { - const timeout = setTimeout(() => { - resolve({}); - }, options.timeout + 1000); + if (!localizacaoMultipla.latitude || !localizacaoMultipla.longitude) { + console.warn('Não foi possível obter localização'); + return { + confiabilidadeGPS: 0, + suspeitaSpoofing: true, + motivoSuspeita: 'Não foi possível obter localização' + }; + } - navigator.geolocation.getCurrentPosition( - async (position) => { - clearTimeout(timeout); - const { latitude, longitude, accuracy } = position.coords; + const { latitude, longitude, precisao, altitude, altitudeAccuracy, heading, speed, confiabilidade, suspeitaSpoofing, motivoSuspeita } = localizacaoMultipla; - // Validar coordenadas - if (isNaN(latitude) || isNaN(longitude) || latitude === 0 || longitude === 0) { - resolve({}); - return; - } + // Tentar obter endereço via reverse geocoding + let endereco = ''; + let cidade = ''; + let estado = ''; + let pais = ''; - // Tentar obter endereço via reverse geocoding - let endereco = ''; - let cidade = ''; - let estado = ''; - let pais = ''; - - try { - const response = await fetch( - `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`, - { - headers: { - 'User-Agent': 'SGSE-App/1.0' - } - } - ); - if (response.ok) { - const data = (await response.json()) as { - address?: { - road?: string; - house_number?: string; - city?: string; - town?: string; - state?: string; - country?: string; - }; - }; - if (data.address) { - const addr = data.address; - if (addr.road) { - endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`; - } - cidade = addr.city || addr.town || ''; - estado = addr.state || ''; - pais = addr.country || ''; - } - } - } catch (error) { - console.warn('Erro ao obter endereço:', error); - } - - resolve({ - latitude, - longitude, - precisao: accuracy, - endereco, - cidade, - estado, - pais, - }); - }, - (error) => { - clearTimeout(timeout); - console.warn('Erro ao obter localização:', error.code, error.message); - resolve({}); - }, - options - ); - }); - - // Se obteve localização, retornar - if (resultado.latitude && resultado.longitude) { - console.log('Localização obtida com sucesso:', resultado); - return resultado; + try { + const response = await fetch( + `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`, + { + headers: { + 'User-Agent': 'SGSE-App/1.0' + } } - } catch (error) { - console.warn('Erro na estratégia de geolocalização:', error); - continue; + ); + if (response.ok) { + const data = (await response.json()) as { + address?: { + road?: string; + house_number?: string; + city?: string; + town?: string; + state?: string; + country?: string; + }; + }; + if (data.address) { + const addr = data.address; + if (addr.road) { + endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`; + } + cidade = addr.city || addr.town || ''; + estado = addr.state || ''; + pais = addr.country || ''; + } + } + } catch (error) { + console.warn('Erro ao obter endereço:', error); + } + + // Validar timezone vs localização + if (typeof navigator !== 'undefined') { + const timezoneAtual = Intl.DateTimeFormat().resolvedOptions().timeZone; + const timezoneEsperado = obterTimezonePorCoordenadas(latitude, longitude); + + // Se timezone é muito diferente, pode ser suspeito + if (timezoneAtual !== timezoneEsperado && timezoneAtual !== 'America/Recife' && timezoneEsperado !== 'America/Recife') { + console.warn(`Timezone inconsistente: esperado ${timezoneEsperado}, atual ${timezoneAtual}`); } } - // Se todas as estratégias falharam, retornar vazio - console.warn('Não foi possível obter localização após todas as tentativas'); - return {}; + console.log('Localização obtida com validações:', { + latitude, + longitude, + confiabilidade: confiabilidade.toFixed(2), + suspeitaSpoofing, + motivoSuspeita + }); + + return { + latitude, + longitude, + precisao, + altitude, + altitudeAccuracy, + heading, + speed, + confiabilidadeGPS: confiabilidade, + suspeitaSpoofing: suspeitaSpoofing || false, + motivoSuspeita, + endereco, + cidade, + estado, + pais + }; } /** @@ -439,6 +691,13 @@ export async function obterInformacoesDispositivo(): Promise('avatar'); - let avatarSelecionado = $state(''); let mostrarBotaoCamera = $state(false); // Estados para Minhas Férias @@ -100,8 +98,19 @@ let erroMensagemChamado = $state(null); let sucessoMensagemChamado = $state(null); - // Galeria de avatares (30 avatares profissionais 3D realistas) - const avatarGallery = generateAvatarGallery(30); + // Avatares padrão disponíveis + const defaultAvatars = [ + '/avatars/avatar-1.png', + '/avatars/avatar-2.png', + '/avatars/avatar-3.png', + '/avatars/avatar-4.png', + '/avatars/avatar-5.png', + '/avatars/avatar-6.png', + '/avatars/avatar-7.png', + '/avatars/avatar-8.png', + '/avatars/avatar-9.png', + '/avatars/avatar-10.png' + ]; // FuncionarioId disponível diretamente do usuário atual const funcionarioIdDisponivel = $derived(currentUser?.data?.funcionarioId ?? null); @@ -441,6 +450,30 @@ return; } + await processarUploadFoto(file); + } + + async function handleEscolherAvatarPadrao(avatarPath: string) { + try { + uploadandoFoto = true; + erroUpload = ''; + + // Buscar a imagem + const response = await fetch(avatarPath); + const blob = await response.blob(); + const file = new File([blob], avatarPath.split('/').pop() || 'avatar.png', { + type: 'image/png' + }); + + await processarUploadFoto(file); + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + erroUpload = errorMessage || 'Erro ao processar avatar padrão'; + uploadandoFoto = false; + } + } + + async function processarUploadFoto(file: File) { uploadandoFoto = true; erroUpload = ''; @@ -463,16 +496,12 @@ // 4. Atualizar perfil com o novo storageId await client.mutation(api.usuarios.atualizarPerfil, { - fotoPerfil: storageId, - avatar: undefined // Remove avatar se colocar foto + fotoPerfil: storageId }); // 5. Aguardar um pouco para garantir que o backend processou await new Promise((resolve) => setTimeout(resolve, 300)); - // 8. Limpar o input para permitir novo upload - input.value = ''; - // 9. Fechar modal após sucesso mostrarModalFoto = false; @@ -496,45 +525,9 @@ uploadandoFoto = false; } - async function handleSelecionarAvatar(avatarUrl: string) { - uploadandoFoto = true; - erroUpload = ''; - - try { - // 2. Salvar avatar selecionado no backend - await client.mutation(api.usuarios.atualizarPerfil, { - avatar: avatarUrl, - fotoPerfil: undefined // Remove foto se colocar avatar - }); - - // 6. Fechar modal após sucesso - mostrarModalFoto = false; - - // Toast de sucesso - const toast = document.createElement('div'); - toast.className = 'toast toast-top toast-end'; - toast.innerHTML = ` -
    - - - - Avatar atualizado com sucesso! -
    - `; - document.body.appendChild(toast); - setTimeout(() => toast.remove(), 3000); - } catch (e: unknown) { - const errorMessage = e instanceof Error ? e.message : String(e); - erroUpload = errorMessage || 'Erro ao salvar avatar'; - } finally { - uploadandoFoto = false; - } - } - function abrirModalFoto() { erroUpload = ''; modoFoto = 'avatar'; - avatarSelecionado = ''; mostrarModalFoto = true; } @@ -571,22 +564,16 @@ onclick={abrirModalFoto} >
    {#if currentUser.data?.fotoPerfilUrl} Foto de perfil - {:else if currentUser.data?.avatar} - Avatar {:else} -
    - {currentUser.data?.nome.substring(0, 2).toUpperCase()} -
    + {/if}
    @@ -2338,18 +2325,16 @@
    {#if currentUser.data?.fotoPerfilUrl} - Foto atual - {:else if currentUser.data?.avatar} - Avatar atual + Foto atual {:else} -
    - {currentUser.data?.nome.substring(0, 2).toUpperCase()} -
    + {/if}
    @@ -2413,82 +2398,37 @@

    - Escolha um dos 30 avatares profissionais para seu + Escolha um dos avatares profissionais para seu perfil

    - {#each avatarGallery as avatar (avatar.id)} + {#each defaultAvatars as avatarPath, i (avatarPath)} {/each}
    - - - + - Dica: Clique uma vez para selecionar, clique duas vezes para aplicar - imediatamente! + Dica: Clique em um avatar para defini-lo como sua foto de perfil.
    - - {#if avatarSelecionado} -
    - -
    - {/if} {:else}
    diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte index 165d1bd..1ec24c7 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/controle-ponto/dispensa/+page.svelte @@ -1,8 +1,7 @@
    @@ -273,14 +364,14 @@

    Selecionar Funcionário

    - {#each funcionarios as funcionario} - {/each} @@ -291,33 +382,40 @@ {#if modoEdicao && registroSelecionado}
    -
    - -
    -
    -

    - - Homologar Registro de Ponto -

    - {#if dataRegistroFormatada} -

    - Registro do dia {dataRegistroFormatada} -

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

    + Homologar Registro de Ponto +

    + {#if dataRegistroFormatada} +
    + + + Registro do dia {dataRegistroFormatada} + +
    + {/if} +
    - -
    + +
    -
    @@ -402,172 +520,201 @@ {#if abaAtiva === 'ajustar'} -
    - -
    - - +
    + +
    +
    +
    +
    + +
    +
    +

    Tipo de Ajuste

    +

    Selecione o tipo de ajuste a ser aplicado

    +
    +
    +
    + +
    +
    - -
    - - - -
    -

    -
    - Início do Período -

    -
    -
    - - -
    - -
    - - -
    -
    -
    - - -
    - -
    - - -
    -

    -
    - Fim do Período -

    -
    -
    - - -
    - -
    - - -
    -
    -
    - - - {#if dataInicioAjuste && horaInicioAjuste && dataFimAjuste && horaFimAjuste} - {@const periodoCalculado = calcularPeriodo(dataInicioAjuste, horaInicioAjuste, dataFimAjuste, horaFimAjuste)} - {#if periodoCalculado.dias > 0 || periodoCalculado.horas > 0 || periodoCalculado.minutos > 0} -
    -
    Período Calculado:
    -
    - {periodoCalculado.dias > 0 ? `${periodoCalculado.dias} dia${periodoCalculado.dias > 1 ? 's' : ''} ` : ''} - {periodoCalculado.horas > 0 ? `${periodoCalculado.horas} hora${periodoCalculado.horas > 1 ? 's' : ''} ` : ''} - {periodoCalculado.minutos > 0 ? `${periodoCalculado.minutos} minuto${periodoCalculado.minutos > 1 ? 's' : ''}` : ''} - {#if periodoCalculado.dias === 0 && periodoCalculado.horas === 0 && periodoCalculado.minutos === 0} - Período inválido - {/if} + +
    +
    +
    +
    +
    + +
    +
    +

    Período do Ajuste

    +

    Defina o período de início e fim do ajuste

    - {/if} - {/if} -
    + Obrigatório +
    - -
    -
    - - +
    + +
    + + +
    +
    +
    + + +
    + +
    + + +
    +

    +
    + Fim do Período +

    +
    +
    + + +
    + +
    + + +
    +
    +
    + + + {#if dataInicioAjuste && horaInicioAjuste && dataFimAjuste && horaFimAjuste} + {@const periodoCalculado = calcularPeriodo(dataInicioAjuste, horaInicioAjuste, dataFimAjuste, horaFimAjuste)} + {#if periodoCalculado.dias > 0 || periodoCalculado.horas > 0 || periodoCalculado.minutos > 0} +
    +
    + +
    Período Calculado
    +
    +
    + {periodoCalculado.dias > 0 ? `${periodoCalculado.dias} dia${periodoCalculado.dias > 1 ? 's' : ''} ` : ''} + {periodoCalculado.horas > 0 ? `${periodoCalculado.horas} hora${periodoCalculado.horas > 1 ? 's' : ''} ` : ''} + {periodoCalculado.minutos > 0 ? `${periodoCalculado.minutos} minuto${periodoCalculado.minutos > 1 ? 's' : ''}` : ''} +
    +
    {/if} - -
    - -
    - - + {/if}
    - -
    - - + +
    +
    +

    +
    + Motivo e Justificativa +

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

    +
    + Observações Adicionais +

    +
    + +
    +
    -
    - -
    @@ -578,55 +725,61 @@ {/if} - {#if funcionarioSelecionado && !modoEdicao} + {#if funcionarioSelecionado}

    Registros do Funcionário

    - {#if registros.length === 0} + {#if registrosQuery?.status === 'Loading'} +
    + +
    + {:else if registros.length === 0}
    Nenhum registro encontrado
    {:else}
    - - - - - - - - - - - - {#each registros as registro} +
    +
    DataTipoHorárioStatusAções
    + - - - - - + + + + + - {/each} - -
    {registro.data} - {getTipoRegistroLabel(registro.tipo)} - {formatarHoraPonto(registro.hora, registro.minuto)} - - {registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'} - - - - DataTipoHorárioStatusAções
    + + + {#each registros as registro} + + {registro.data} + + {getTipoRegistroLabel(registro.tipo)} + + {formatarHoraPonto(registro.hora, registro.minuto)} + + + {registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'} + + + + + + + {/each} + + +
    {/if}
    @@ -634,27 +787,30 @@ {/if} - {#if !modoEdicao} -
    -
    -

    - Histórico de Homologações - {#if funcionarioSelecionado} - - - Funcionário selecionado - - {:else} - - - Todas as homologações do seu time - - {/if} -

    - - {#if homologacoes.length === 0} -
    - Nenhuma homologação encontrada -
    +
    +
    +

    + Histórico de Homologações + {#if funcionarioSelecionado} + + - Funcionário selecionado + {:else} + + - Todas as homologações do seu time + + {/if} +

    + + {#if homologacoesQuery?.status === 'Loading'} +
    + +
    + {:else if homologacoes.length === 0} +
    + Nenhuma homologação encontrada +
    + {:else}
    @@ -667,6 +823,9 @@ + {#if isGestor} + + {/if} @@ -723,6 +882,35 @@ {homologacao.observacoes || '-'} + {#if isGestor} + + {/if} {/each} @@ -731,6 +919,167 @@ {/if} + + + {#if mostrandoModalDetalhes && homologacaoSelecionada} + + {/if} + + + {#if mostrandoModalExcluir && homologacaoParaExcluir} + {/if} diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte index 28d9449..515fd6a 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte @@ -65,9 +65,9 @@ $: needsScroll = filtered.length > 8; -
    +
    -
    Detalhes Motivo ObservaçõesAções
    +
    + + {#if homologacao.registroId} + + {/if} + +
    +
    - - - - - - - - - - - - - {#each filtered as f} - - - - - - - - + + {/each} + {/if} + +
    NomeCPFMatrículaTipoCidadeUFAções
    {f.nome}{f.cpf}{f.matricula}{f.simboloTipo}{f.cidade}{f.uf} - +
    +
    -
    - -
    - Exibindo {filtered.length} de {list.length} funcionário(s) + +
    + Exibindo {filtered.length} de {list.length} funcionário(s) +
    diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte index 302548d..b2d838e 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/+page.svelte @@ -15,6 +15,7 @@ APOSENTADO_OPTIONS, } from "$lib/utils/constants"; import PrintModal from "$lib/components/PrintModal.svelte"; + import { MapPin } from "lucide-svelte"; const client = useConvexClient(); @@ -203,6 +204,16 @@ Imprimir Ficha +
    diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/enderecos-marcacao/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/enderecos-marcacao/+page.svelte new file mode 100644 index 0000000..955a565 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/[funcionarioId]/enderecos-marcacao/+page.svelte @@ -0,0 +1,497 @@ + + +
    + + + + +
    +
    +
    + +
    +
    +

    Endereços de Marcação

    +

    + {#if funcionario} + Gerenciar locais permitidos para registro de ponto de {funcionario.nome} + {:else} + Carregando... + {/if} +

    +
    +
    + {#if enderecosParaAssociar.length > 0} + + {/if} +
    + + + {#if mostrarModalAssociacao} + + {/if} + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + + {#if associacoes.length === 0} +
    + + + + + Este funcionário não possui endereços específicos associados. O sistema usará + automaticamente os endereços tipo "Sede Principal" configurados globalmente. + +
    + {/if} + + +
    + {#if associacoesFiltradas.length === 0} +
    +
    + +

    + {termoBusca + ? 'Nenhuma associação encontrada' + : 'Nenhum endereço associado'} +

    +
    +
    + {:else} + {#each associacoesFiltradas as associacao (associacao._id)} + {@const raioUsado = associacao.raioMetros} +
    +
    +
    +
    +
    +

    {associacao.endereco.nome}

    + + {associacao.ativo ? 'Ativa' : 'Inativa'} + + + {tiposLabel[associacao.endereco.tipo] || associacao.endereco.tipo} + +
    +
    +

    + Endereço: {associacao.endereco.endereco} +

    +

    + Cidade: {associacao.endereco.cidade}/ + {associacao.endereco.estado} +

    +

    + Raio Permitido: + {#if raioUsado >= 1000} + {@const raioKm = (raioUsado / 1000).toFixed(2)} + {raioKm} km + {:else} + {raioUsado} metros + {/if} + {#if associacao.raioMetrosPersonalizado !== null && + associacao.raioMetrosPersonalizado !== undefined} + + Personalizado + + {:else} + + Padrão ({associacao.endereco.raioMetros}m) + + {/if} +

    + {#if associacao.dataInicio || associacao.dataFim} +

    + Período: + {#if associacao.dataInicio} + {@const dataInicioFormatada = new Date(associacao.dataInicio + 'T00:00:00').toLocaleDateString('pt-BR')} + {dataInicioFormatada} + {:else} + ... + {/if} + até + {#if associacao.dataFim} + {@const dataFimFormatada = new Date(associacao.dataFim + 'T00:00:00').toLocaleDateString('pt-BR')} + {dataFimFormatada} + {:else} + ... + {/if} +

    + {/if} +
    +
    + +
    + + +
    +
    +
    +
    + {/each} + {/if} +
    +
    + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte index 521766a..04c223e 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte @@ -1,5 +1,5 @@ -
    +
    -
    -
    -
    - +
    +
    +
    +
    +
    +
    + +
    +
    +

    + Registro de Pontos +

    +

    + Gerencie e visualize os registros de ponto dos funcionários com informações detalhadas e relatórios +

    +
    -
    -

    Registro de Pontos

    -

    Gerencie e visualize os registros de ponto dos funcionários

    + {#if estatisticas} +
    +
    +

    Total de Registros

    +

    {estatisticas.totalRegistros}

    +
    +
    +

    Funcionários

    +

    {estatisticas.totalFuncionarios}

    +
    +
    +
    + + {estatisticas.totalRegistros > 0 + ? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1) + : 0}% dentro do prazo + + Ativo +
    +
    + {/if} +
    +
    + + + {#if estatisticas} +
    + +
    +
    +
    +
    +

    Total de Registros

    +

    {estatisticas.totalRegistros}

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

    Dentro do Prazo

    +

    {estatisticas.dentroDoPrazo}

    +

    + {estatisticas.totalRegistros > 0 + ? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1) + : 0}% do total +

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

    Fora do Prazo

    +

    {estatisticas.foraDoPrazo}

    +

    + {estatisticas.totalRegistros > 0 + ? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1) + : 0}% do total +

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

    Funcionários

    +

    {estatisticas.totalFuncionarios}

    +

    + {estatisticas.funcionariosDentroPrazo} dentro, {estatisticas.funcionariosForaPrazo} fora +

    +
    +
    + +
    +
    +
    -
    + {/if} + + + {#if estatisticas} +
    +
    +
    +

    +
    + +
    + Visão Geral das Estatísticas +

    +
    +
    + + {#if !chartInstance && estatisticas} +
    + +
    + {/if} +
    +
    +
    + {/if} -
    +
    -

    - - Filtros -

    -
    +
    +
    + +
    +

    Filtros de Busca

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

    + 2 + Endereço Físico +

    +
    +
    + +
    + +
    + + +
    +
    + +
    + + +
    + +
    +
    + +
    + { + const cepLimpo = onlyDigits(cep); + if (cepLimpo.length === 8) { + buscarEnderecoPorCEP(); + } + }} + placeholder="00000-000" + maxlength="9" + class="input input-bordered focus:input-primary focus:ring-2 focus:ring-primary/20 w-full pr-10" + /> + {#if buscandoCEP} +
    + +
    + {/if} +
    +
    + + {#if buscandoCEP} + Buscando endereço... + {:else if cep} + Digite o CEP completo para buscar automaticamente + {/if} + +
    +
    + +
    + + +
    + +
    + + +
    +
    + +
    + + +
    +
    + + +
    +
    +
    +

    + 3 + Localização GPS e Configuração +

    +
    +
    + +
    +
    +
    + +
    +
    +

    Coordenadas GPS

    +

    + As coordenadas são usadas para validar a localização dos registros de ponto. O sistema busca automaticamente via Google Maps (se configurado) ou OpenStreetMap. +

    +
    +
    +
    + +
    +
    + +
    + + {#if buscandoCoordenadas} +
    + +
    + {/if} +
    +
    + Formato: -8.047600 +
    +
    + +
    + +
    + + {#if buscandoCoordenadas} +
    + +
    + {/if} +
    +
    + Formato: -34.877000 +
    +
    + +
    + + +
    + + Em metros. Ex: 100, 500, 1000 + +
    +
    +
    +
    +
    + + + +
    +
    + {/if} + + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    + {#if enderecosFiltrados.length === 0} +
    +
    + +

    + {termoBusca ? 'Nenhum endereço encontrado' : 'Nenhum endereço cadastrado'} +

    +
    +
    + {:else} + {#each enderecosFiltrados as enderecoItem (enderecoItem._id)} +
    +
    +
    +
    +
    +

    {enderecoItem.nome}

    + + {enderecoItem.ativo ? 'Ativo' : 'Inativo'} + + + {tiposLabel[enderecoItem.tipo]} + +
    + + {#if enderecoItem.descricao} +

    {enderecoItem.descricao}

    + {/if} + +
    +

    + Endereço: {enderecoItem.endereco} + {#if (enderecoItem as any).bairro} + - Bairro: {(enderecoItem as any).bairro} + {/if} +

    +

    + Cidade: {enderecoItem.cidade}/{enderecoItem.estado} + {#if enderecoItem.cep} + - CEP: {enderecoItem.cep} + {/if} +

    +

    + Coordenadas: {enderecoItem.latitude.toFixed(6)}, {enderecoItem.longitude.toFixed(6)} +

    +

    + Raio Permitido: + {#if enderecoItem.raioMetros >= 1000} + {@const raioKm = (enderecoItem.raioMetros / 1000).toFixed(2)} + {raioKm} km + {:else} + {enderecoItem.raioMetros} metros + {/if} +

    +
    +
    + +
    + + {#if enderecoItem.ativo} + + {:else} + + {/if} +
    +
    +
    +
    + {/each} + {/if} +
    +
    + diff --git a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte index 89f0240..703ce53 100644 --- a/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte @@ -1,12 +1,11 @@