diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index ebe51d3..0000000 --- a/.editorconfig +++ /dev/null @@ -1,12 +0,0 @@ -# EditorConfig is awesome: https://EditorConfig.org - -# top-most EditorConfig file -root = true - -[*] -indent_style = space -indent_size = 2 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = false -insert_final_newline = false \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..6562bcb --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..fb40e07 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,18 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": [ + "prettier-plugin-svelte", + "prettier-plugin-tailwindcss" + ], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cf7e14f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + // "editor.formatOnSave": true, + // "editor.defaultFormatter": "biomejs.biome", + // "editor.codeActionsOnSave": { + // "source.fixAll.biome": "always" + // }, + // "[typescript]": { + // "editor.defaultFormatter": "biomejs.biome" + // }, + // "[svelte]": { + // "editor.defaultFormatter": "biomejs.biome" + // } +} \ No newline at end of file diff --git a/CONFIGURACAO_PUSH_NOTIFICATIONS.md b/CONFIGURACAO_PUSH_NOTIFICATIONS.md deleted file mode 100644 index 2f072f7..0000000 --- a/CONFIGURACAO_PUSH_NOTIFICATIONS.md +++ /dev/null @@ -1,117 +0,0 @@ -# 🔔 Configuração de Push Notifications - -## Passo 1: Configurar VAPID Keys - -### 1.1 Gerar VAPID Keys (se ainda não tiver) - -Execute no diretório do backend: -```bash -cd packages/backend -bunx web-push generate-vapid-keys -``` - -Isso gerará duas chaves: -- **Public Key**: Segura para expor no frontend -- **Private Key**: Deve ser mantida em segredo, apenas no backend - -### 1.2 Configurar no Convex (Backend) - -As variáveis de ambiente no Convex são configuradas via dashboard ou CLI: - -#### Opção A: Via Dashboard Convex -1. Acesse https://dashboard.convex.dev -2. Selecione seu projeto -3. Vá em **Settings** > **Environment Variables** -4. Adicione as seguintes variáveis: - -``` -VAPID_PUBLIC_KEY=BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks -VAPID_PRIVATE_KEY=KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4 -FRONTEND_URL=http://localhost:5173 -``` - -#### Opção B: Via CLI Convex -```bash -cd packages/backend -npx convex env set VAPID_PUBLIC_KEY "BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks" -npx convex env set VAPID_PRIVATE_KEY "KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4" -npx convex env set FRONTEND_URL "http://localhost:5173" -``` - -### 1.3 Configurar no Frontend - -Crie um arquivo `.env` no diretório `apps/web/` com: - -```env -VITE_VAPID_PUBLIC_KEY=BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks -``` - -**Importante**: Reinicie o servidor de desenvolvimento após criar/modificar o `.env`. - -## Passo 2: Configurar FRONTEND_URL - -A variável `FRONTEND_URL` é usada nos templates de email para gerar links de volta ao sistema. - -### Para Desenvolvimento: -``` -FRONTEND_URL=http://localhost:5173 -``` - -### Para Produção: -``` -FRONTEND_URL=https://seu-dominio.com -``` - -## Passo 3: Testar Push Notifications - -### 3.1 Registrar Subscription no Frontend - -O sistema automaticamente solicita permissão e registra a subscription quando: -1. O usuário faz login -2. Acessa o chat pela primeira vez -3. O Service Worker é instalado - -### 3.2 Verificar se está funcionando - -1. Abra o DevTools do navegador (F12) -2. Vá na aba **Application** > **Service Workers** -3. Verifique se o Service Worker está registrado -4. Vá em **Application** > **Notifications** -5. Verifique se a permissão está concedida - -### 3.3 Testar envio de push - -1. Abra o chat em duas abas/janelas diferentes -2. Faça login com usuários diferentes -3. Envie uma mensagem de um usuário para o outro -4. A mensagem deve aparecer como notificação push na outra aba - -## Troubleshooting - -### Push notifications não funcionam - -1. **Verificar VAPID keys**: Certifique-se de que as keys estão configuradas corretamente -2. **Verificar Service Worker**: O arquivo `sw.js` deve estar em `/static/sw.js` -3. **Verificar permissões**: O navegador deve ter permissão para notificações -4. **Verificar console**: Procure por erros no console do navegador e do Convex - -### Erro "VAPID keys não configuradas" - -- Verifique se as variáveis de ambiente estão configuradas no Convex -- Reinicie o servidor Convex após configurar as variáveis -- Verifique se os nomes das variáveis estão corretos (case-sensitive) - -### Service Worker não registra - -- Verifique se o arquivo `sw.js` existe em `apps/web/static/sw.js` -- Verifique se o servidor está servindo arquivos estáticos corretamente -- Limpe o cache do navegador e tente novamente - -## Segurança - -⚠️ **IMPORTANTE**: -- A **Private Key** nunca deve ser exposta no frontend -- Use variáveis de ambiente diferentes para desenvolvimento e produção -- Regenere as keys se suspeitar de comprometimento -- Mantenha as keys em segredo (não commite no Git) - diff --git a/GUIA_TESTE_PUSH_NOTIFICATIONS.md b/GUIA_TESTE_PUSH_NOTIFICATIONS.md deleted file mode 100644 index 1e31371..0000000 --- a/GUIA_TESTE_PUSH_NOTIFICATIONS.md +++ /dev/null @@ -1,214 +0,0 @@ -# 🧪 Guia de Teste - Push Notifications e Melhorias do Chat - -## Pré-requisitos - -1. ✅ Convex rodando (`cd packages/backend && bun run dev`) -2. ✅ Frontend rodando (`cd apps/web && bun run dev`) -3. ✅ Variáveis de ambiente configuradas (ver `configurar-variaveis-ambiente.md`) -4. ✅ Usuários criados no sistema - -## Teste 1: Configuração de Push Notifications - -### 1.1 Verificar Service Worker - -1. Abra o navegador em `http://localhost:5173` -2. Faça login no sistema -3. Abra DevTools (F12) -4. Vá em **Application** > **Service Workers** -5. ✅ Verifique se `sw.js` está registrado e ativo - -### 1.2 Solicitar Permissão de Notificações - -1. Abra o chat no sistema -2. O sistema deve solicitar permissão para notificações automaticamente -3. Clique em **Permitir** -4. ✅ Verifique em **Application** > **Notifications** que a permissão está concedida - -### 1.3 Verificar Subscription - -1. Abra o Console do DevTools -2. Execute: -```javascript -navigator.serviceWorker.ready.then(reg => { - reg.pushManager.getSubscription().then(sub => { - console.log('Subscription:', sub); - }); -}); -``` -3. ✅ Deve retornar um objeto Subscription com endpoint e keys - -## Teste 2: Envio e Recebimento de Push Notifications - -### 2.1 Teste Básico - -1. Abra o sistema em **duas abas diferentes** (ou dois navegadores) -2. Faça login com usuários diferentes em cada aba -3. Na aba 1, abra uma conversa com o usuário da aba 2 -4. Envie uma mensagem da aba 1 -5. ✅ A aba 2 deve receber uma notificação push (mesmo se estiver em background) - -### 2.2 Teste de Menção - -1. Na aba 1, envie uma mensagem mencionando o usuário da aba 2 (use @) -2. ✅ A aba 2 deve receber uma notificação push destacada - -### 2.3 Teste Offline - -1. Feche a aba 2 (ou coloque o navegador em modo offline) -2. Envie uma mensagem da aba 1 -3. ✅ O sistema deve enviar um email para o usuário da aba 2 (se estiver offline) - -## Teste 3: Edição de Mensagens - -### 3.1 Editar Mensagem Própria - -1. Envie uma mensagem no chat -2. Clique no ícone ✏️ ao lado da mensagem -3. Edite o conteúdo -4. Pressione **Ctrl+Enter** ou clique em **Salvar** -5. ✅ A mensagem deve ser atualizada com indicador "(editado)" - -### 3.2 Tentar Editar Mensagem de Outro Usuário - -1. Tente editar uma mensagem de outro usuário -2. ✅ Não deve aparecer o botão de editar (ou deve retornar erro) - -## Teste 4: Soft Delete de Mensagens - -### 4.1 Deletar Mensagem Própria - -1. Envie uma mensagem -2. Clique no ícone 🗑️ ao lado da mensagem -3. Confirme a exclusão -4. ✅ A mensagem deve ser marcada como "Mensagem deletada" - -### 4.2 Tentar Deletar Mensagem de Outro Usuário - -1. Tente deletar uma mensagem de outro usuário -2. ✅ Não deve aparecer o botão de deletar (ou deve retornar erro) - -## Teste 5: Respostas Encadeadas - -### 5.1 Responder Mensagem - -1. Clique no botão **↪️ Responder** em uma mensagem -2. ✅ Deve aparecer um preview da mensagem original no campo de input -3. Digite sua resposta e envie -4. ✅ A mensagem enviada deve mostrar o preview da mensagem original acima - -### 5.2 Visualizar Thread - -1. Envie várias respostas para diferentes mensagens -2. ✅ Cada resposta deve mostrar claramente qual mensagem está respondendo - -## Teste 6: Preview de Links - -### 6.1 Enviar Mensagem com URL - -1. Envie uma mensagem contendo uma URL (ex: `https://www.google.com`) -2. Aguarde alguns segundos -3. ✅ Deve aparecer um preview do link abaixo da mensagem com: - - Imagem (se disponível) - - Título - - Descrição - - Site/nome do domínio - -### 6.2 Testar Diferentes URLs - -Teste com diferentes tipos de URLs: -- ✅ Google: `https://www.google.com` -- ✅ YouTube: `https://www.youtube.com` -- ✅ Artigo de notícia -- ✅ Site sem Open Graph (deve funcionar mesmo assim) - -## Teste 7: Busca Full-Text - -### 7.1 Busca Básica - -1. Envie algumas mensagens com palavras específicas -2. Use a busca no chat (se implementada) ou a query de busca -3. ✅ Deve encontrar mensagens mesmo com acentos diferentes - -### 7.2 Busca com Filtros - -1. Busque mensagens por: - - ✅ Remetente específico - - ✅ Tipo (texto, arquivo, imagem) - - ✅ Período de data -2. ✅ Os filtros devem funcionar corretamente - -## Teste 8: Rate Limiting de Emails - -### 8.1 Enviar Múltiplos Emails - -1. Configure o sistema para enviar emails -2. Tente enviar mais de 10 emails em 1 minuto -3. ✅ Deve retornar erro de rate limit após o limite - -### 8.2 Verificar Delay Exponencial - -1. Aguarde o rate limit ser aplicado -2. Tente enviar novamente -3. ✅ Deve haver um delay antes de permitir novo envio - -## Checklist de Validação - -- [ ] Service Worker registrado e funcionando -- [ ] Permissão de notificações concedida -- [ ] Push notifications sendo recebidas -- [ ] Emails sendo enviados quando usuário offline -- [ ] Edição de mensagens funcionando -- [ ] Soft delete funcionando -- [ ] Respostas encadeadas funcionando -- [ ] Preview de links aparecendo -- [ ] Busca full-text funcionando -- [ ] Rate limiting de emails funcionando - -## Problemas Comuns e Soluções - -### Push notifications não funcionam - -**Problema**: Notificações não aparecem - -**Soluções**: -1. Verifique se as VAPID keys estão configuradas no Convex -2. Verifique se `VITE_VAPID_PUBLIC_KEY` está no `.env` do frontend -3. Reinicie o servidor Convex e frontend -4. Limpe o cache do navegador -5. Verifique o console para erros - -### Preview de links não aparece - -**Problema**: Links não geram preview - -**Soluções**: -1. Verifique se a URL é válida (começa com http:// ou https://) -2. Aguarde alguns segundos (processamento é assíncrono) -3. Verifique o console do Convex para erros na extração -4. Alguns sites bloqueiam scrapers - isso é normal - -### Edição não funciona - -**Problema**: Botão de editar não aparece ou não funciona - -**Soluções**: -1. Verifique se a mensagem é sua (só pode editar próprias mensagens) -2. Verifique se a mensagem não foi deletada -3. Verifique o console para erros -4. Certifique-se de que a mutation `editarMensagem` está funcionando - -## Relatório de Testes - -Após completar os testes, preencha: - -- **Data**: ___________ -- **Testador**: ___________ -- **Ambiente**: [ ] Desenvolvimento [ ] Produção -- **Navegador**: ___________ -- **Resultados**: ___________ - -**Observações**: -_______________________________________ -_______________________________________ -_______________________________________ - diff --git a/PASSO_A_PASSO_CONFIGURACAO.md b/PASSO_A_PASSO_CONFIGURACAO.md deleted file mode 100644 index 46a90ce..0000000 --- a/PASSO_A_PASSO_CONFIGURACAO.md +++ /dev/null @@ -1,163 +0,0 @@ -# 📋 Passo a Passo - Configuração Completa - -## ✅ Passo 1: Configurar VAPID Keys - -### 1.1 Configurar no Convex (Backend) - -**Opção A: Via Dashboard (Recomendado)** - -1. Acesse https://dashboard.convex.dev -2. Selecione seu projeto -3. Vá em **Settings** > **Environment Variables** -4. Adicione as seguintes variáveis: - -``` -VAPID_PUBLIC_KEY=BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks -VAPID_PRIVATE_KEY=KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4 -FRONTEND_URL=http://localhost:5173 -``` - -**Opção B: Via CLI** - -Execute do diretório raiz do projeto: - -```powershell -cd packages/backend -npx convex env set VAPID_PUBLIC_KEY "BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks" -npx convex env set VAPID_PRIVATE_KEY "KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4" -npx convex env set FRONTEND_URL "http://localhost:5173" -``` - -**Opção C: Usar Script Automático** - -Execute na raiz do projeto: - -```powershell -.\scripts\configurar-push-notifications.ps1 -``` - -### 1.2 Configurar no Frontend - -Crie o arquivo `apps/web/.env` com: - -```env -VITE_VAPID_PUBLIC_KEY=BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks -``` - -**Importante**: Reinicie o servidor frontend após criar/modificar o `.env` - -## ✅ Passo 2: Configurar FRONTEND_URL - -A variável `FRONTEND_URL` já foi configurada no Passo 1.1. Ela é usada nos templates de email para gerar links de volta ao sistema. - -**Para Desenvolvimento:** -``` -FRONTEND_URL=http://localhost:5173 -``` - -**Para Produção (quando fizer deploy):** -``` -FRONTEND_URL=https://seu-dominio.com -``` - -## ✅ Passo 3: Testar Funcionalidades - -### 3.1 Verificar Configuração Inicial - -1. **Inicie o Convex** (se não estiver rodando): - ```bash - cd packages/backend - bun run dev - ``` - -2. **Inicie o Frontend** (se não estiver rodando): - ```bash - cd apps/web - bun run dev - ``` - -3. **Verifique as variáveis de ambiente**: - - No Convex Dashboard: Settings > Environment Variables - - No Frontend: Verifique se `apps/web/.env` existe - -### 3.2 Testar Push Notifications - -1. Abra `http://localhost:5173` no navegador -2. Faça login no sistema -3. Abra DevTools (F12) > **Application** > **Service Workers** -4. ✅ Verifique se `sw.js` está registrado -5. ✅ Verifique se a permissão de notificações foi solicitada - -### 3.3 Testar Chat Completo - -Siga o guia completo em `GUIA_TESTE_PUSH_NOTIFICATIONS.md` para testar: -- ✅ Push notifications -- ✅ Edição de mensagens -- ✅ Soft delete -- ✅ Respostas encadeadas -- ✅ Preview de links -- ✅ Busca full-text - -## 🔍 Verificação Rápida - -Execute estes comandos para verificar: - -### Verificar Variáveis no Convex: -```bash -cd packages/backend -npx convex env list -``` - -Deve mostrar: -- `VAPID_PUBLIC_KEY` -- `VAPID_PRIVATE_KEY` -- `FRONTEND_URL` - -### Verificar Frontend: -```bash -cd apps/web -# Verifique se o arquivo .env existe -cat .env -``` - -## 🐛 Troubleshooting - -### Problema: Variáveis não aparecem no Convex - -**Solução**: -- Certifique-se de estar no projeto correto no dashboard -- Reinicie o servidor Convex após configurar -- Use `npx convex env list` para verificar - -### Problema: Frontend não encontra VAPID_PUBLIC_KEY - -**Solução**: -- Verifique se o arquivo `.env` está em `apps/web/.env` -- Verifique se a variável começa com `VITE_` -- Reinicie o servidor frontend -- Limpe o cache do navegador - -### Problema: Service Worker não registra - -**Solução**: -- Verifique se `apps/web/static/sw.js` existe -- Abra DevTools > Application > Service Workers -- Clique em "Unregister" e recarregue a página -- Verifique o console para erros - -## 📝 Checklist Final - -- [ ] VAPID keys configuradas no Convex -- [ ] FRONTEND_URL configurada no Convex -- [ ] VITE_VAPID_PUBLIC_KEY no `.env` do frontend -- [ ] Convex rodando -- [ ] Frontend rodando -- [ ] Service Worker registrado -- [ ] Permissão de notificações concedida -- [ ] Push notifications funcionando -- [ ] Todas as funcionalidades testadas - -## 🎉 Pronto! - -Após completar os 3 passos, o sistema estará totalmente configurado e pronto para uso! - diff --git a/RESUMO_CONFIGURACAO_COMPLETA.md b/RESUMO_CONFIGURACAO_COMPLETA.md deleted file mode 100644 index a7a83b9..0000000 --- a/RESUMO_CONFIGURACAO_COMPLETA.md +++ /dev/null @@ -1,68 +0,0 @@ -# ✅ Resumo da Configuração Completa - -## 📋 Passo 1: VAPID Keys - CONCLUÍDO - -### Keys Geradas: -- **Public Key**: `BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks` -- **Private Key**: `KWkJLMxCuCPQQiRXIJEt06G4pTdW0FaUN_vMyY02sc4` - -### Configuração Necessária: - -**1. No Convex (Backend):** -- Via Dashboard: Settings > Environment Variables -- Adicionar: `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `FRONTEND_URL` -- OU executar: `.\scripts\configurar-push-notifications.ps1` - -**2. No Frontend:** -- Criar arquivo `apps/web/.env` -- Adicionar: `VITE_VAPID_PUBLIC_KEY=BDerX0lK_hBCLpC7EbuxoJb2EJ7bcCLaHWxkNumVbvrx9w0MmJduHxJOP3WBwBP-SpQGcueMOyHCv7LFK3KnQks` - -## 📋 Passo 2: FRONTEND_URL - CONCLUÍDO - -- Valor padrão: `http://localhost:5173` -- Configurar no Convex junto com as VAPID keys -- Usado nos templates de email para links de retorno - -## 📋 Passo 3: Testes - PRONTO PARA EXECUTAR - -### Arquivos Criados: -- ✅ `PushNotificationManager.svelte` - Registra subscription automaticamente -- ✅ `GUIA_TESTE_PUSH_NOTIFICATIONS.md` - Guia completo de testes -- ✅ `PASSO_A_PASSO_CONFIGURACAO.md` - Instruções detalhadas -- ✅ `CONFIGURACAO_PUSH_NOTIFICATIONS.md` - Documentação técnica -- ✅ `scripts/configurar-push-notifications.ps1` - Script automático - -### Para Testar: - -1. **Configure as variáveis** (ver Passo 1) -2. **Reinicie os servidores** (Convex e Frontend) -3. **Faça login** no sistema -4. **Siga o guia**: `GUIA_TESTE_PUSH_NOTIFICATIONS.md` - -## 🎯 Checklist de Configuração - -- [ ] VAPID keys configuradas no Convex Dashboard -- [ ] FRONTEND_URL configurada no Convex -- [ ] Arquivo `apps/web/.env` criado com VITE_VAPID_PUBLIC_KEY -- [ ] Convex reiniciado após configurar variáveis -- [ ] Frontend reiniciado após criar .env -- [ ] Service Worker registrado (verificar DevTools) -- [ ] Permissão de notificações concedida -- [ ] Testes executados conforme guia - -## 📚 Documentação Disponível - -1. **CONFIGURACAO_PUSH_NOTIFICATIONS.md** - Configuração técnica detalhada -2. **PASSO_A_PASSO_CONFIGURACAO.md** - Instruções passo a passo -3. **GUIA_TESTE_PUSH_NOTIFICATIONS.md** - Guia completo de testes -4. **configurar-variaveis-ambiente.md** - Referência rápida - -## 🚀 Próximos Passos - -1. Execute o script de configuração OU configure manualmente -2. Reinicie os servidores -3. Teste todas as funcionalidades -4. Reporte qualquer problema encontrado - -**Tudo pronto para configuração e testes!** 🎉 - diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..25fd275 --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,20 @@ +import { config as svelteConfigBase } from '@sgse-app/eslint-config/svelte'; +import svelteConfig from './svelte.config.js'; +import ts from 'typescript-eslint'; +import { defineConfig } from "eslint/config"; + +/** @type {import("eslint").Linter.Config} */ +export default defineConfig([ + ...svelteConfigBase, + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: ts.parser, + svelteConfig + } + } + } +]) \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index 7061285..61cc60c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" }, "devDependencies": { + "@sgse-app/eslint-config": "*", "@sveltejs/adapter-auto": "^6.1.0", "@sveltejs/kit": "^2.31.1", "@sveltejs/vite-plugin-svelte": "^6.1.2", @@ -27,6 +28,8 @@ "vite": "^7.1.2" }, "dependencies": { + "eslint": "catalog:", + "@convex-dev/better-auth": "^0.9.7", "@dicebear/collection": "^9.2.4", "@dicebear/core": "^9.2.4", "@fullcalendar/core": "^6.1.19", @@ -35,13 +38,16 @@ "@fullcalendar/list": "^6.1.19", "@fullcalendar/multimonth": "^6.1.19", "@internationalized/date": "^3.10.0", + "@mmailaender/convex-better-auth-svelte": "^0.2.0", "@sgse-app/backend": "*", "@tanstack/svelte-form": "^1.19.2", "@types/papaparse": "^5.3.14", + "better-auth": "catalog:", "convex": "catalog:", "convex-svelte": "^0.0.11", "date-fns": "^4.1.0", "emoji-picker-element": "^1.27.0", + "is-network-error": "^1.3.0", "jspdf": "^3.0.3", "jspdf-autotable": "^5.0.2", "lucide-svelte": "^0.552.0", diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index da08e6d..6633a80 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -1,13 +1,9 @@ -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces declare global { - namespace App { - // interface Error {} - // interface Locals {} - // interface PageData {} - // interface PageState {} - // interface Platform {} - } + namespace App { + interface Locals { + token: string | undefined; + } + } } export {}; diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index de58268..08c379c 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -1,9 +1,9 @@ import type { Handle } from "@sveltejs/kit"; - -// Middleware desabilitado - proteção de rotas feita no lado do cliente -// para compatibilidade com localStorage do authStore +import { createAuth } from "@sgse-app/backend/convex/auth"; +import { getToken } from "@mmailaender/convex-better-auth-svelte/sveltekit"; export const handle: Handle = async ({ event, resolve }) => { + event.locals.token = await getToken(createAuth, event.cookies); + return resolve(event); }; - diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 6de9cf0..7881eb4 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -1,7 +1,13 @@ -import { createAuthClient } from "better-auth/client"; +/** + * Cliente Better Auth para frontend SvelteKit + * + * Configurado para trabalhar com Convex via plugin convexClient. + * Este cliente será usado para autenticação quando Better Auth estiver ativo. + */ + +import { createAuthClient } from "better-auth/svelte"; import { convexClient } from "@convex-dev/better-auth/client/plugins"; export const authClient = createAuthClient({ - baseURL: "http://localhost:5173", plugins: [convexClient()], }); diff --git a/apps/web/src/lib/components/ActionGuard.svelte b/apps/web/src/lib/components/ActionGuard.svelte index d88ca22..a616643 100644 --- a/apps/web/src/lib/components/ActionGuard.svelte +++ b/apps/web/src/lib/components/ActionGuard.svelte @@ -2,9 +2,8 @@ import { useQuery } from "convex-svelte"; import { api } from "@sgse-app/backend/convex/_generated/api"; import type { Id } from "@sgse-app/backend/convex/_generated/dataModel"; - import { authStore } from "$lib/stores/auth.svelte"; import { loginModalStore } from "$lib/stores/loginModal.svelte"; - import { AlertTriangle } from "lucide-svelte"; + import { TriangleAlert } from "lucide-svelte"; interface Props { recurso: string; @@ -17,18 +16,21 @@ let verificando = $state(true); let permitido = $state(false); + // Usuário atual + const currentUser = useQuery(api.auth.getCurrentUser, {}); + const permissaoQuery = $derived( - authStore.usuario + currentUser?.data ? useQuery(api.permissoesAcoes.verificarAcao, { - usuarioId: authStore.usuario._id as Id<"usuarios">, + usuarioId: currentUser.data._id as Id<"usuarios">, recurso, acao, }) - : null + : null, ); $effect(() => { - if (!authStore.autenticado) { + if (!currentUser?.data) { verificando = false; permitido = false; const currentPath = window.location.pathname; @@ -60,7 +62,7 @@
- +

Acesso Negado

diff --git a/apps/web/src/lib/components/AprovarAusencias.svelte b/apps/web/src/lib/components/AprovarAusencias.svelte index 68f5b53..49b6072 100644 --- a/apps/web/src/lib/components/AprovarAusencias.svelte +++ b/apps/web/src/lib/components/AprovarAusencias.svelte @@ -35,7 +35,7 @@ } const totalDias = $derived( - calcularDias(solicitacao.dataInicio, solicitacao.dataFim) + calcularDias(solicitacao.dataInicio, solicitacao.dataFim), ); async function aprovar() { @@ -52,10 +52,15 @@ if (onSucesso) onSucesso(); } catch (e) { const mensagemErro = e instanceof Error ? e.message : String(e); - + // Verificar se é erro de permissão - if (mensagemErro.includes("permissão") || mensagemErro.includes("permission") || mensagemErro.includes("Você não tem permissão")) { - mensagemErroModal = "Você não tem permissão para aprovar esta solicitação de ausência. Apenas o gestor responsável pelo time do funcionário pode realizar esta ação."; + if ( + mensagemErro.includes("permissão") || + mensagemErro.includes("permission") || + mensagemErro.includes("Você não tem permissão") + ) { + mensagemErroModal = + "Você não tem permissão para aprovar esta solicitação de ausência. Apenas o gestor responsável pelo time do funcionário pode realizar esta ação."; mostrarModalErro = true; } else { erro = mensagemErro; @@ -85,10 +90,15 @@ if (onSucesso) onSucesso(); } catch (e) { const mensagemErro = e instanceof Error ? e.message : String(e); - + // Verificar se é erro de permissão - if (mensagemErro.includes("permissão") || mensagemErro.includes("permission") || mensagemErro.includes("Você não tem permissão")) { - mensagemErroModal = "Você não tem permissão para reprovar esta solicitação de ausência. Apenas o gestor responsável pelo time do funcionário pode realizar esta ação."; + if ( + mensagemErro.includes("permissão") || + mensagemErro.includes("permission") || + mensagemErro.includes("Você não tem permissão") + ) { + mensagemErroModal = + "Você não tem permissão para reprovar esta solicitação de ausência. Apenas o gestor responsável pelo time do funcionário pode realizar esta ação."; mostrarModalErro = true; } else { erro = mensagemErro; @@ -125,7 +135,9 @@

-

Aprovar/Reprovar Ausência

+

+ Aprovar/Reprovar Ausência +

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

@@ -154,14 +166,18 @@

Nome

-

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

+

+ {solicitacao.funcionario?.nome || "N/A"} +

{#if solicitacao.time}

Time

{solicitacao.time.nome}
@@ -192,21 +208,33 @@ Período da Ausência
-
+
Data Início
-
+
{new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")}
-
+
Data Fim
-
+
{new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}
-
+
Total de Dias
-
+
{totalDias}
dias corridos
@@ -385,7 +413,8 @@ @@ -395,4 +424,3 @@ margin: 0 auto; } - diff --git a/apps/web/src/lib/components/CalendarioAfastamentos.svelte b/apps/web/src/lib/components/CalendarioAfastamentos.svelte index fc72de1..b419a67 100644 --- a/apps/web/src/lib/components/CalendarioAfastamentos.svelte +++ b/apps/web/src/lib/components/CalendarioAfastamentos.svelte @@ -155,45 +155,60 @@
-
+

Calendário de Afastamentos

- +
Filtrar:
diff --git a/apps/web/src/lib/components/FileUpload.svelte b/apps/web/src/lib/components/FileUpload.svelte index 2f3227c..442fdbc 100644 --- a/apps/web/src/lib/components/FileUpload.svelte +++ b/apps/web/src/lib/components/FileUpload.svelte @@ -1,7 +1,15 @@ - diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index cc0b25b..f184db7 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -3,60 +3,67 @@ import { goto } from "$app/navigation"; import logo from "$lib/assets/logo_governo_PE.png"; import type { Snippet } from "svelte"; - import { authStore } from "$lib/stores/auth.svelte"; import { loginModalStore } from "$lib/stores/loginModal.svelte"; - import { useConvexClient } from "convex-svelte"; + import { useQuery } from "convex-svelte"; import { api } from "@sgse-app/backend/convex/_generated/api"; 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 { getBrowserInfo } from "$lib/utils/browserInfo"; import { getAvatarUrl } from "$lib/utils/avatarGenerator"; - import { Menu, User, Home, UserPlus, XCircle, LogIn, Tag, Plus, Check } from "lucide-svelte"; + import { + Menu, + User, + Home, + UserPlus, + XCircle, + LogIn, + Tag, + Plus, + Check, + } from "lucide-svelte"; + import { authClient } from "$lib/auth"; let { children }: { children: Snippet } = $props(); - const convex = useConvexClient(); - - // Caminho atual da página const currentPath = $derived(page.url.pathname); + const currentUser = useQuery(api.auth.getCurrentUser, {}); + // Função para obter a URL do avatar/foto do usuário const avatarUrlDoUsuario = $derived(() => { - const usuario = authStore.usuario; - if (!usuario) return null; - + if (!currentUser.data) return null; + // Prioridade: fotoPerfilUrl > avatar > fallback com nome - if (usuario.fotoPerfilUrl) { - return usuario.fotoPerfilUrl; - } - if (usuario.avatar) { - return getAvatarUrl(usuario.avatar); + if (currentUser.data.fotoPerfil) { + return currentUser.data.fotoPerfil; } + // Fallback: gerar avatar baseado no nome - return getAvatarUrl(usuario.nome); + return getAvatarUrl(currentUser.data.nome); }); // Função para gerar classes do menu ativo function getMenuClasses(isActive: boolean) { - const baseClasses = "group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105"; - + const baseClasses = + "group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105"; + if (isActive) { return `${baseClasses} border-primary bg-primary text-white shadow-lg scale-105`; } - - return `${baseClasses} border-primary/30 bg-gradient-to-br from-base-100 to-base-200 text-base-content hover:from-primary hover:to-primary/80 hover:text-white`; + + return `${baseClasses} border-primary/30 bg-linear-to-br from-base-100 to-base-200 text-base-content hover:from-primary hover:to-primary/80 hover:text-white`; } // Função para gerar classes do botão "Solicitar Acesso" function getSolicitarClasses(isActive: boolean) { - const baseClasses = "group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105"; - + const baseClasses = + "group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105"; + if (isActive) { return `${baseClasses} border-success bg-success text-white shadow-lg scale-105`; } - - return `${baseClasses} border-success/30 bg-gradient-to-br from-success/10 to-success/20 text-base-content hover:from-success hover:to-success/80 hover:text-white`; + + return `${baseClasses} border-success/30 bg-linear-to-br from-success/10 to-success/20 text-base-content hover:from-success hover:to-success/80 hover:text-white`; } const setores = [ @@ -118,68 +125,50 @@ erroLogin = ""; carregandoLogin = true; - try { - // Usar mutation normal com WebRTC para capturar IP - // getBrowserInfo() tenta obter o IP local via WebRTC - const browserInfo = await getBrowserInfo(); - - const resultado = await convex.mutation(api.autenticacao.login, { - matriculaOuEmail: matricula.trim(), - senha: senha, - userAgent: browserInfo.userAgent || undefined, - ipAddress: browserInfo.ipAddress, - }); + // const browserInfo = await getBrowserInfo(); - if (resultado.sucesso) { - authStore.login(resultado.usuario, resultado.token); - closeLoginModal(); - - // Redirecionar baseado no role - if (resultado.usuario.role.nome === "ti" || resultado.usuario.role.nivel === 0) { - goto("/ti/painel-administrativo"); - } else if (resultado.usuario.role.nome === "rh") { - goto("/recursos-humanos"); - } else { - goto("/"); - } - } else { - erroLogin = resultado.erro || "Erro ao fazer login"; - } - } catch (error) { - console.error("Erro ao fazer login:", error); - erroLogin = "Erro ao conectar com o servidor. Tente novamente."; - } finally { - carregandoLogin = false; + const result = await authClient.signIn.email( + { email: matricula.trim(), password: senha }, + { + onError: (ctx) => { + alert(ctx.error.message); + }, + }, + ); + + if (result.data) { + closeLoginModal(); + goto("/"); + } else { + erroLogin = "Erro ao fazer login"; } } async function handleLogout() { - if (authStore.token) { - try { - await convex.mutation(api.autenticacao.logout, { - token: authStore.token, - }); - } catch (error) { - console.error("Erro ao fazer logout:", error); - } + const result = await authClient.signOut(); + if (result.error) { + console.error("Sign out error:", result.error); } - authStore.logout(); goto("/"); } -
@@ -480,7 +531,9 @@ {#if showAboutModal} -
- + {/if} -{#if authStore.autenticado} +{#if currentUser.data} {/if} @@ -571,7 +635,8 @@ - diff --git a/apps/web/src/lib/components/ausencias/CalendarioAusencias.svelte b/apps/web/src/lib/components/ausencias/CalendarioAusencias.svelte index 355f50c..986e577 100644 --- a/apps/web/src/lib/components/ausencias/CalendarioAusencias.svelte +++ b/apps/web/src/lib/components/ausencias/CalendarioAusencias.svelte @@ -14,7 +14,10 @@ dataFim: string; status: "aguardando_aprovacao" | "aprovado" | "reprovado"; }>; - onPeriodoSelecionado?: (periodo: { dataInicio: string; dataFim: string }) => void; + onPeriodoSelecionado?: (periodo: { + dataInicio: string; + dataFim: string; + }) => void; modoVisualizacao?: "month" | "multiMonth"; readonly?: boolean; } @@ -45,7 +48,10 @@ }> = $state([]); // Cores por status - const coresStatus: Record = { + const coresStatus: Record< + string, + { bg: string; border: string; text: string } + > = { aguardando_aprovacao: { bg: "#f59e0b", border: "#d97706", text: "#ffffff" }, // Laranja aprovado: { bg: "#10b981", border: "#059669", text: "#ffffff" }, // Verde reprovado: { bg: "#ef4444", border: "#dc2626", text: "#ffffff" }, // Vermelho @@ -65,7 +71,8 @@ status: string; }; }> = ausenciasExistentes.map((ausencia, index) => { - const cor = coresStatus[ausencia.status] || coresStatus.aguardando_aprovacao; + const cor = + coresStatus[ausencia.status] || coresStatus.aguardando_aprovacao; return { id: `ausencia-${index}`, title: `${getStatusTexto(ausencia.status)} - ${calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias`, @@ -95,7 +102,7 @@ }, }); } - + eventos = novosEventos; } @@ -129,11 +136,11 @@ inicio1: Date, fim1: Date, inicio2: string, - fim2: string + fim2: string, ): boolean { const d2Inicio = new Date(inicio2); const d2Fim = new Date(fim2); - + // Verificar sobreposição: início1 <= fim2 && início2 <= fim1 return inicio1 <= d2Fim && d2Inicio <= fim1; } @@ -141,14 +148,14 @@ // Helper: Verificar se período selecionado sobrepõe com ausências existentes function verificarSobreposicaoComAusencias(inicio: Date, fim: Date): boolean { if (!ausenciasExistentes || ausenciasExistentes.length === 0) return false; - + // Verificar apenas ausências aprovadas ou aguardando aprovação const ausenciasBloqueantes = ausenciasExistentes.filter( - (a) => a.status === "aprovado" || a.status === "aguardando_aprovacao" + (a) => a.status === "aprovado" || a.status === "aguardando_aprovacao", ); - + return ausenciasBloqueantes.some((ausencia) => - verificarSobreposicao(inicio, fim, ausencia.dataInicio, ausencia.dataFim) + verificarSobreposicao(inicio, fim, ausencia.dataInicio, ausencia.dataFim), ); } @@ -158,11 +165,11 @@ const cellDate = new Date(info.date); const inicio = new Date(dataInicio); const fim = new Date(dataFim); - + cellDate.setHours(0, 0, 0, 0); inicio.setHours(0, 0, 0, 0); fim.setHours(0, 0, 0, 0); - + if (cellDate >= inicio && cellDate <= fim) { info.el.classList.add("fc-day-selected"); } else { @@ -185,7 +192,9 @@ // Verificar se a data está dentro de alguma ausência aprovada ou aguardando aprovação const estaBloqueado = ausenciasExistentes - .filter((a) => a.status === "aprovado" || a.status === "aguardando_aprovacao") + .filter( + (a) => a.status === "aprovado" || a.status === "aguardando_aprovacao", + ) .some((ausencia) => { const inicio = new Date(ausencia.dataInicio); const fim = new Date(ausencia.dataFim); @@ -204,23 +213,23 @@ // Helper: Atualizar todos os dias selecionados no calendário function atualizarDiasSelecionados() { if (!calendar || !calendarEl || !dataInicio || !dataFim || readonly) return; - + // Usar a API do FullCalendar para iterar sobre todas as células visíveis const view = calendar.view; if (!view) return; - + const inicio = new Date(dataInicio); const fim = new Date(dataFim); inicio.setHours(0, 0, 0, 0); fim.setHours(0, 0, 0, 0); - + // O FullCalendar renderiza as células, então podemos usar dayCellDidMount // Mas também precisamos atualizar células existentes const cells = calendarEl.querySelectorAll(".fc-daygrid-day"); cells.forEach((cell) => { // Remover classe primeiro cell.classList.remove("fc-day-selected"); - + // Tentar obter a data do aria-label ou do elemento const ariaLabel = cell.getAttribute("aria-label"); if (ariaLabel) { @@ -242,7 +251,13 @@ // Helper: Atualizar todos os dias bloqueados no calendário function atualizarDiasBloqueados() { - if (!calendar || !calendarEl || readonly || !ausenciasExistentes || ausenciasExistentes.length === 0) { + if ( + !calendar || + !calendarEl || + readonly || + !ausenciasExistentes || + ausenciasExistentes.length === 0 + ) { // Remover classes de bloqueio se não houver ausências if (calendarEl) { const cells = calendarEl.querySelectorAll(".fc-daygrid-day"); @@ -250,23 +265,23 @@ } return; } - + const cells = calendarEl.querySelectorAll(".fc-daygrid-day"); const ausenciasBloqueantes = ausenciasExistentes.filter( - (a) => a.status === "aprovado" || a.status === "aguardando_aprovacao" + (a) => a.status === "aprovado" || a.status === "aguardando_aprovacao", ); - + if (ausenciasBloqueantes.length === 0) { cells.forEach((cell) => cell.classList.remove("fc-day-blocked")); return; } - + cells.forEach((cell) => { cell.classList.remove("fc-day-blocked"); - + // Tentar obter a data de diferentes formas let cellDate: Date | null = null; - + // Método 1: aria-label const ariaLabel = cell.getAttribute("aria-label"); if (ariaLabel) { @@ -279,7 +294,7 @@ // Ignorar } } - + // Método 2: data-date attribute if (!cellDate) { const dataDate = cell.getAttribute("data-date"); @@ -294,7 +309,7 @@ } } } - + // Método 3: Tentar obter do número do dia e contexto do calendário if (!cellDate && calendar.view) { const dayNumberEl = cell.querySelector(".fc-daygrid-day-number"); @@ -315,10 +330,10 @@ } } } - + if (cellDate) { cellDate.setHours(0, 0, 0, 0); - + const estaBloqueado = ausenciasBloqueantes.some((ausencia) => { const inicio = new Date(ausencia.dataInicio); const fim = new Date(ausencia.dataFim); @@ -326,7 +341,7 @@ fim.setHours(0, 0, 0, 0); return cellDate >= inicio && cellDate <= fim; }); - + if (estaBloqueado) { cell.classList.add("fc-day-blocked"); } @@ -337,18 +352,18 @@ // Atualizar eventos quando mudanças ocorrem (evitar loop infinito) $effect(() => { if (!calendar || selecionando) return; // Não atualizar durante seleção - + // Garantir que temos as ausências antes de atualizar const ausencias = ausenciasExistentes; - + atualizarEventos(); - + // Usar requestAnimationFrame para evitar múltiplas atualizações durante seleção requestAnimationFrame(() => { if (calendar && !selecionando) { calendar.removeAllEvents(); calendar.addEventSource(eventos); - + // Atualizar classes de seleção e bloqueio quando as datas mudarem setTimeout(() => { atualizarDiasSelecionados(); @@ -357,16 +372,17 @@ } }); }); - + // Efeito separado para atualizar quando ausências mudarem $effect(() => { if (!calendar || readonly) return; - + const ausencias = ausenciasExistentes; - const ausenciasBloqueantes = ausencias?.filter( - (a) => a.status === "aprovado" || a.status === "aguardando_aprovacao" - ) || []; - + const ausenciasBloqueantes = + ausencias?.filter( + (a) => a.status === "aprovado" || a.status === "aguardando_aprovacao", + ) || []; + // Se houver ausências bloqueantes, forçar atualização if (ausenciasBloqueantes.length > 0) { setTimeout(() => { @@ -386,12 +402,14 @@ calendar = new Calendar(calendarEl, { plugins: [dayGridPlugin, interactionPlugin, multiMonthPlugin], - initialView: modoVisualizacao === "multiMonth" ? "multiMonthYear" : "dayGridMonth", + initialView: + modoVisualizacao === "multiMonth" ? "multiMonthYear" : "dayGridMonth", locale: ptBrLocale, headerToolbar: { left: "prev,next today", center: "title", - right: modoVisualizacao === "multiMonth" ? "multiMonthYear" : "dayGridMonth", + right: + modoVisualizacao === "multiMonth" ? "multiMonthYear" : "dayGridMonth", }, height: "auto", selectable: !readonly, @@ -443,7 +461,9 @@ // Validar sobreposição com ausências existentes if (verificarSobreposicaoComAusencias(inicio, fim)) { - alert("Este período sobrepõe com uma ausência já aprovada ou aguardando aprovação. Por favor, escolha outro período."); + alert( + "Este período sobrepõe com uma ausência já aprovada ou aguardando aprovação. Por favor, escolha outro período.", + ); calendar?.unselect(); selecionando = false; return; @@ -459,7 +479,7 @@ // Não remover seleção imediatamente para manter visualização // calendar?.unselect(); - + // Liberar flag após um pequeno delay para garantir que o estado foi atualizado setTimeout(() => { selecionando = false; @@ -472,7 +492,9 @@ if (readonly) { const status = info.event.extendedProps.status; const texto = getStatusTexto(status); - alert(`Ausência ${texto}\nPeríodo: ${new Date(info.event.startStr).toLocaleDateString("pt-BR")} até ${new Date(calcularDataFim(info.event.endStr)).toLocaleDateString("pt-BR")}`); + alert( + `Ausência ${texto}\nPeríodo: ${new Date(info.event.startStr).toLocaleDateString("pt-BR")} até ${new Date(calcularDataFim(info.event.endStr)).toLocaleDateString("pt-BR")}`, + ); } }, @@ -491,35 +513,39 @@ selectAllow: (selectInfo) => { const hoje = new Date(); hoje.setHours(0, 0, 0, 0); - + // Bloquear datas passadas if (new Date(selectInfo.start) < hoje) { return false; } - + // Verificar sobreposição com ausências existentes - if (!readonly && ausenciasExistentes && ausenciasExistentes.length > 0) { + if ( + !readonly && + ausenciasExistentes && + ausenciasExistentes.length > 0 + ) { const inicioSelecao = new Date(selectInfo.start); const fimSelecao = new Date(selectInfo.end); fimSelecao.setDate(fimSelecao.getDate() - 1); // FullCalendar usa exclusive end - + inicioSelecao.setHours(0, 0, 0, 0); fimSelecao.setHours(0, 0, 0, 0); - + if (verificarSobreposicaoComAusencias(inicioSelecao, fimSelecao)) { return false; } } - + return true; }, - + // Adicionar classe CSS aos dias selecionados e bloqueados dayCellDidMount: (info) => { atualizarClasseSelecionado(info); atualizarClasseBloqueado(info); }, - + // Atualizar quando as datas mudarem (navegação do calendário) datesSet: () => { setTimeout(() => { @@ -527,7 +553,7 @@ atualizarDiasBloqueados(); }, 100); }, - + // Garantir que as classes sejam aplicadas após renderização inicial viewDidMount: () => { setTimeout(() => { @@ -541,20 +567,25 @@ // Highlight de fim de semana e aplicar classe de bloqueio dayCellClassNames: (arg) => { const classes: string[] = []; - + if (arg.date.getDay() === 0 || arg.date.getDay() === 6) { classes.push("fc-day-weekend-custom"); } - + // Verificar se o dia está bloqueado - if (!readonly && ausenciasExistentes && ausenciasExistentes.length > 0) { + if ( + !readonly && + ausenciasExistentes && + ausenciasExistentes.length > 0 + ) { const cellDate = new Date(arg.date); cellDate.setHours(0, 0, 0, 0); - + const ausenciasBloqueantes = ausenciasExistentes.filter( - (a) => a.status === "aprovado" || a.status === "aguardando_aprovacao" + (a) => + a.status === "aprovado" || a.status === "aguardando_aprovacao", ); - + const estaBloqueado = ausenciasBloqueantes.some((ausencia) => { const inicio = new Date(ausencia.dataInicio); const fim = new Date(ausencia.dataFim); @@ -562,12 +593,12 @@ fim.setHours(0, 0, 0, 0); return cellDate >= inicio && cellDate <= fim; }); - + if (estaBloqueado) { classes.push("fc-day-blocked"); } } - + return classes; }, }); @@ -585,32 +616,39 @@ {#if !readonly}
- - - -
-

Como usar:

-
    -
  • Clique e arraste no calendário para selecionar o período de ausência
  • -
  • Você pode visualizar suas ausências já solicitadas no calendário
  • -
  • A data de início não pode ser no passado
  • -
-
+ + + +
+

Como usar:

+
    +
  • + Clique e arraste no calendário para selecionar o período de + ausência +
  • +
  • + Você pode visualizar suas ausências já solicitadas no calendário +
  • +
  • A data de início não pode ser no passado
  • +
+
- {#if ausenciasExistentes && ausenciasExistentes.filter(a => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0} - {@const ausenciasBloqueantes = ausenciasExistentes.filter(a => a.status === "aprovado" || a.status === "aguardando_aprovacao")} + {#if ausenciasExistentes && ausenciasExistentes.filter((a) => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0} + {@const ausenciasBloqueantes = ausenciasExistentes.filter( + (a) => a.status === "aprovado" || a.status === "aguardando_aprovacao", + )}

Atenção: Períodos Indisponíveis

-

Os dias marcados em vermelho estão bloqueados porque você já possui solicitações aprovadas ou aguardando aprovação para esses períodos.

-

Você não pode criar novas solicitações que sobreponham esses períodos. Escolha um período diferente.

+

+ Os dias marcados em vermelho + estão bloqueados porque você já possui solicitações + aprovadas + ou aguardando aprovação para esses períodos. +

+

+ Você não pode criar novas solicitações que sobreponham esses + períodos. Escolha um período diferente. +

@@ -647,30 +695,46 @@ {#if ausenciasExistentes.length > 0 || readonly}
-
-
- Aguardando Aprovação -
-
-
- Aprovado -
-
-
- Reprovado -
- {#if !readonly && ausenciasExistentes && ausenciasExistentes.filter(a => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0} -
-
+
+
+ Aguardando Aprovação +
+
+
+ Aprovado +
+
+
+ Reprovado +
+ {#if !readonly && ausenciasExistentes && ausenciasExistentes.filter((a) => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0} +
+
Dias Bloqueados (Indisponíveis)
{/if}
- - {#if !readonly && ausenciasExistentes && ausenciasExistentes.filter(a => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0} + + {#if !readonly && ausenciasExistentes && ausenciasExistentes.filter((a) => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0}

- Dias bloqueados não podem ser selecionados para novas solicitações + Dias bloqueados não podem + ser selecionados para novas solicitações

{/if} @@ -679,7 +743,9 @@ {#if dataInicio && dataFim && !readonly} -
+

Data Início

-

{new Date(dataInicio).toLocaleDateString("pt-BR")}

+

+ {new Date(dataInicio).toLocaleDateString("pt-BR")} +

Data Fim

-

{new Date(dataFim).toLocaleDateString("pt-BR")}

+

+ {new Date(dataFim).toLocaleDateString("pt-BR")} +

Total de Dias

-

{calcularDias(dataInicio, dataFim)} dias

+

+ {calcularDias(dataInicio, dataFim)} dias +

@@ -720,7 +792,12 @@ - diff --git a/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte b/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte index 319c97f..5ce13b6 100644 --- a/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte +++ b/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte @@ -26,26 +26,31 @@ let dataFim = $state(""); let motivo = $state(""); let processando = $state(false); - + // Estados para modal de erro let mostrarModalErro = $state(false); let mensagemErroModal = $state(""); let detalhesErroModal = $state(""); // Buscar ausências existentes para exibir no calendário - const ausenciasExistentesQuery = useQuery(api.ausencias.listarMinhasSolicitacoes, { - funcionarioId, - }); + const ausenciasExistentesQuery = useQuery( + api.ausencias.listarMinhasSolicitacoes, + { + funcionarioId, + }, + ); // Filtrar apenas ausências aprovadas ou aguardando aprovação (que bloqueiam novas solicitações) const ausenciasExistentes = $derived( (ausenciasExistentesQuery?.data || []) - .filter((a) => a.status === "aprovado" || a.status === "aguardando_aprovacao") + .filter( + (a) => a.status === "aprovado" || a.status === "aguardando_aprovacao", + ) .map((a) => ({ - dataInicio: a.dataInicio, - dataFim: a.dataFim, + dataInicio: a.dataInicio, + dataFim: a.dataFim, status: a.status as "aguardando_aprovacao" | "aprovado", - })) + })), ); // Calcular dias selecionados @@ -117,14 +122,15 @@ }); toast.success("Solicitação de ausência criada com sucesso!"); - + if (onSucesso) { onSucesso(); } } catch (error) { console.error("Erro ao criar solicitação:", error); - const mensagemErro = error instanceof Error ? error.message : String(error); - + const mensagemErro = + error instanceof Error ? error.message : String(error); + // Verificar se é erro de sobreposição de período if ( mensagemErro.includes("Já existe uma solicitação") || @@ -149,7 +155,10 @@ detalhesErroModal = ""; } - function handlePeriodoSelecionado(periodo: { dataInicio: string; dataFim: string }) { + function handlePeriodoSelecionado(periodo: { + dataInicio: string; + dataFim: string; + }) { dataInicio = periodo.dataInicio; dataFim = periodo.dataFim; } @@ -158,7 +167,9 @@
-

Solicite uma ausência para assuntos particulares

+

+ Solicite uma ausência para assuntos particulares +

@@ -230,22 +241,25 @@

Selecione o Período

- Clique e arraste no calendário para selecionar o período de ausência + Clique e arraste no calendário para selecionar o período de + ausência

{#if ausenciasExistentesQuery === undefined}
- Carregando ausências existentes... + Carregando ausências existentes...
{:else} - + {/if} {#if dataInicio && dataFim} @@ -279,13 +293,16 @@

Informe o Motivo

- Descreva o motivo da sua solicitação de ausência (mínimo 10 caracteres) + Descreva o motivo da sua solicitação de ausência (mínimo 10 + caracteres)

{#if dataInicio && dataFim} -
+

Data Início

-

{new Date(dataInicio).toLocaleDateString("pt-BR")}

+

+ {new Date(dataInicio).toLocaleDateString("pt-BR")} +

Data Fim

-

{new Date(dataFim).toLocaleDateString("pt-BR")}

+

+ {new Date(dataFim).toLocaleDateString("pt-BR")} +

Total de Dias

-

+

{totalDias} dias

@@ -478,4 +501,3 @@ margin: 0 auto; } - diff --git a/apps/web/src/lib/components/chat/ChatList.svelte b/apps/web/src/lib/components/chat/ChatList.svelte index 003ecdb..4468fe3 100644 --- a/apps/web/src/lib/components/chat/ChatList.svelte +++ b/apps/web/src/lib/components/chat/ChatList.svelte @@ -9,10 +9,10 @@ import NewConversationModal from "./NewConversationModal.svelte"; const client = useConvexClient(); - + // Buscar todos os usuários para o chat const usuarios = useQuery(api.usuarios.listarParaChat, {}); - + // Buscar o perfil do usuário logado const meuPerfil = useQuery(api.usuarios.obterPerfil, {}); @@ -24,54 +24,77 @@ // Debug: monitorar carregamento de dados $effect(() => { - console.log("📊 [ChatList] Usuários carregados:", usuarios?.data?.length || 0); - console.log("👤 [ChatList] Meu perfil:", meuPerfil?.data?.nome || "Carregando..."); - console.log("🆔 [ChatList] Meu ID:", meuPerfil?.data?._id || "Não encontrado"); + console.log( + "📊 [ChatList] Usuários carregados:", + usuarios?.data?.length || 0, + ); + console.log( + "👤 [ChatList] Meu perfil:", + meuPerfil?.data?.nome || "Carregando...", + ); + console.log( + "🆔 [ChatList] Meu ID:", + meuPerfil?.data?._id || "Não encontrado", + ); if (usuarios?.data) { const meuId = meuPerfil?.data?._id; const meusDadosNaLista = usuarios.data.find((u: any) => u._id === meuId); if (meusDadosNaLista) { - console.warn("⚠️ [ChatList] ATENÇÃO: Meu usuário está na lista do backend!", meusDadosNaLista.nome); + console.warn( + "⚠️ [ChatList] ATENÇÃO: Meu usuário está na lista do backend!", + meusDadosNaLista.nome, + ); } } }); const usuariosFiltrados = $derived.by(() => { if (!usuarios?.data || !Array.isArray(usuarios.data)) return []; - + // Se não temos o perfil ainda, retornar lista vazia para evitar mostrar usuários incorretos if (!meuPerfil?.data) { console.log("⏳ [ChatList] Aguardando perfil do usuário..."); return []; } - + const meuId = meuPerfil.data._id; - + // Filtrar o próprio usuário da lista (filtro de segurança no frontend) let listaFiltrada = usuarios.data.filter((u: any) => u._id !== meuId); - + // Log se ainda estiver na lista após filtro (não deveria acontecer) const aindaNaLista = listaFiltrada.find((u: any) => u._id === meuId); if (aindaNaLista) { - console.error("❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!"); + console.error( + "❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!", + ); } - + // Aplicar busca por nome/email/matrícula if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); - listaFiltrada = listaFiltrada.filter((u: any) => - u.nome?.toLowerCase().includes(query) || - u.email?.toLowerCase().includes(query) || - u.matricula?.toLowerCase().includes(query) + listaFiltrada = listaFiltrada.filter( + (u: any) => + u.nome?.toLowerCase().includes(query) || + u.email?.toLowerCase().includes(query) || + u.matricula?.toLowerCase().includes(query), ); } - + // Ordenar: Online primeiro, depois por nome return listaFiltrada.sort((a: any, b: any) => { - const statusOrder = { online: 0, ausente: 1, externo: 2, em_reuniao: 3, offline: 4 }; - const statusA = statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4; - const statusB = statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4; - + const statusOrder = { + online: 0, + ausente: 1, + externo: 2, + em_reuniao: 3, + offline: 4, + }; + const statusA = + statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4; + const statusB = + statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4; + if (statusA !== statusB) return statusA - statusB; return a.nome.localeCompare(b.nome); }); @@ -101,19 +124,22 @@ try { processando = true; console.log("🔄 Clicou no usuário:", usuario.nome, "ID:", usuario._id); - + // Criar ou buscar conversa individual com este usuário console.log("📞 Chamando mutation criarOuBuscarConversaIndividual..."); - const conversaId = await client.mutation(api.chat.criarOuBuscarConversaIndividual, { - outroUsuarioId: usuario._id, - }); - + const conversaId = await client.mutation( + api.chat.criarOuBuscarConversaIndividual, + { + outroUsuarioId: usuario._id, + }, + ); + console.log("✅ Conversa criada/encontrada. ID:", conversaId); - + // Abrir a conversa console.log("📂 Abrindo conversa..."); abrirConversa(conversaId as any); - + console.log("✅ Conversa aberta com sucesso!"); } catch (error) { console.error("❌ Erro ao abrir conversa:", error); @@ -122,7 +148,9 @@ stack: error instanceof Error ? error.stack : undefined, usuario: usuario, }); - alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`); + alert( + `Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`, + ); } finally { processando = false; } @@ -142,19 +170,17 @@ // Filtrar conversas por tipo e busca const conversasFiltradas = $derived(() => { if (!conversas?.data) return []; - - let lista = conversas.data.filter((c: any) => - c.tipo === "grupo" || c.tipo === "sala_reuniao" + + let lista = conversas.data.filter( + (c: any) => c.tipo === "grupo" || c.tipo === "sala_reuniao", ); - + // Aplicar busca if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); - lista = lista.filter((c: any) => - c.nome?.toLowerCase().includes(query) - ); + lista = lista.filter((c: any) => c.nome?.toLowerCase().includes(query)); } - + return lista; }); @@ -165,7 +191,9 @@ abrirConversa(conversa._id); } catch (error) { console.error("Erro ao abrir conversa:", error); - alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`); + alert( + `Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`, + ); } finally { processando = false; } @@ -218,7 +246,7 @@ 💬 Conversas ({conversasFiltradas().length})

- +
@@ -247,17 +279,21 @@
{#if activeTab === "usuarios"} - {#if usuarios?.data && usuariosFiltrados.length > 0} - {#each usuariosFiltrados as usuario (usuario._id)} - - {/each} - {:else if !usuarios?.data} - -
- -
- {:else} - -
- + {/each} + {:else if !usuarios?.data} + +
+ +
+ {:else} + +
- - -

Nenhum usuário encontrado

-
+ + + +

Nenhum usuário encontrado

+
{/if} {:else} @@ -341,23 +388,48 @@ {#each conversasFiltradas() as conversa (conversa._id)}
+ + + + + + + + + + + {#if count?.data && count.data > 0} + - {count.data > 9 ? "9+" : count.data} - - {/if} + > + {count.data > 9 ? '9+' : count.data} + + {/if} - -
-
-
-
-
- + +
+
+
+
+
+ {/if} -{#if isOpen && !isMinimized} - {@const winWidth = windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0)} - {@const winHeight = windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0)} - {@const bottomPos = position.y === 0 ? '1.5rem' : `${Math.max(0, winHeight - position.y - windowSize.height)}px`} - {@const rightPos = position.x === 0 ? '1.5rem' : `${Math.max(0, winWidth - position.x - windowSize.width)}px`} -
- -
- -
-
- -

- -
- {#if avatarUrlDoUsuario()} - {authStore.usuario?.nome - {:else} - - - - - - - {/if} -
- Mensagens -

+ onmousedown={handleMouseDown} + role="button" + tabindex="0" + aria-label="Arrastar janela do chat" + > + +
+
+ +

+ +
+ {#if avatarUrlDoUsuario()} + {currentUser?.data?.nome + {:else} + + + + + + + {/if} +
+ Mensagens +

- -
- - + +
+ + - - + + - - -
-
+ + +
+
- -
- {#if !activeConversation} - - {:else} - - {/if} + +
+ {#if !activeConversation} + + {:else} + + {/if} - - -
handleResizeStart(e, 'n')} - style="border-radius: 24px 24px 0 0;" - >
- -
handleResizeStart(e, 's')} - style="border-radius: 0 0 24px 24px;" - >
- -
handleResizeStart(e, 'w')} - style="border-radius: 24px 0 0 24px;" - >
- -
handleResizeStart(e, 'e')} - style="border-radius: 0 24px 24px 0;" - >
- -
handleResizeStart(e, 'nw')} - style="border-radius: 24px 0 0 0;" - >
-
handleResizeStart(e, 'ne')} - style="border-radius: 0 24px 0 0;" - >
-
handleResizeStart(e, 'sw')} - style="border-radius: 0 0 0 24px;" - >
-
handleResizeStart(e, 'se')} - style="border-radius: 0 0 24px 0;" - >
-
-
+ + +
handleResizeStart(e, 'n')} + onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'n')} + style="border-radius: 24px 24px 0 0;" + >
+ +
handleResizeStart(e, 's')} + onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 's')} + style="border-radius: 0 0 24px 24px;" + >
+ +
handleResizeStart(e, 'w')} + onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'w')} + style="border-radius: 24px 0 0 24px;" + >
+ +
handleResizeStart(e, 'e')} + onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'e')} + style="border-radius: 0 24px 24px 0;" + >
+ +
handleResizeStart(e, 'nw')} + onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'nw')} + style="border-radius: 24px 0 0 0;" + >
+
handleResizeStart(e, 'ne')} + onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'ne')} + style="border-radius: 0 24px 0 0;" + >
+
handleResizeStart(e, 'sw')} + onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'sw')} + style="border-radius: 0 0 0 24px;" + >
+
handleResizeStart(e, 'se')} + onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'se')} + style="border-radius: 0 0 24px 0;" + >
+
+
{/if} {#if showGlobalNotificationPopup && globalNotificationMessage} -
{ - showGlobalNotificationPopup = false; - globalNotificationMessage = null; - if (globalNotificationTimeout) { - clearTimeout(globalNotificationTimeout); - } - // Abrir chat e conversa ao clicar - abrirChat(); - abrirConversa(globalNotificationMessage.conversaId as Id<"conversas">); - }} - > -
-
- - - -
-
-

Nova mensagem de {globalNotificationMessage.remetente}

-

{globalNotificationMessage.conteudo}

-

Clique para abrir

-
- -
-
+ {@const notificationMsg = globalNotificationMessage} +
{ + const conversaIdToOpen = notificationMsg?.conversaId; + showGlobalNotificationPopup = false; + globalNotificationMessage = null; + if (globalNotificationTimeout) { + clearTimeout(globalNotificationTimeout); + } + // Abrir chat e conversa ao clicar + if (conversaIdToOpen) { + abrirChat(); + abrirConversa(conversaIdToOpen as Id<'conversas'>); + } + }} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + const conversaIdToOpen = notificationMsg?.conversaId; + showGlobalNotificationPopup = false; + globalNotificationMessage = null; + if (globalNotificationTimeout) { + clearTimeout(globalNotificationTimeout); + } + if (conversaIdToOpen) { + abrirChat(); + abrirConversa(conversaIdToOpen as Id<'conversas'>); + } + } + }} + > +
+
+ + + +
+
+

+ Nova mensagem de {notificationMsg.remetente} +

+

+ {notificationMsg.conteudo} +

+

Clique para abrir

+
+ +
+
{/if} - diff --git a/apps/web/src/lib/components/chat/ChatWindow.svelte b/apps/web/src/lib/components/chat/ChatWindow.svelte index 63115d9..9cecb9d 100644 --- a/apps/web/src/lib/components/chat/ChatWindow.svelte +++ b/apps/web/src/lib/components/chat/ChatWindow.svelte @@ -10,35 +10,51 @@ import ScheduleMessageModal from "./ScheduleMessageModal.svelte"; import SalaReuniaoManager from "./SalaReuniaoManager.svelte"; import { getAvatarUrl } from "$lib/utils/avatarGenerator"; - import { authStore } from "$lib/stores/auth.svelte"; - import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from "lucide-svelte"; + import { + Bell, + X, + ArrowLeft, + LogOut, + MoreVertical, + Users, + Clock, + XCircle, + } from "lucide-svelte"; interface Props { conversaId: string; } let { conversaId }: Props = $props(); - + const client = useConvexClient(); - + + // Token é passado automaticamente via interceptadores em +layout.svelte + let showScheduleModal = $state(false); let showSalaManager = $state(false); let showAdminMenu = $state(false); let showNotificacaoModal = $state(false); const conversas = useQuery(api.chat.listarConversas, {}); - const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId: conversaId as any }); + const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { + conversaId: conversaId as Id<"conversas">, + }); const conversa = $derived(() => { console.log("🔍 [ChatWindow] Buscando conversa ID:", conversaId); console.log("📋 [ChatWindow] Conversas disponíveis:", conversas?.data); - + if (!conversas?.data || !Array.isArray(conversas.data)) { - console.log("⚠️ [ChatWindow] conversas.data não é um array ou está vazio"); + console.log( + "⚠️ [ChatWindow] conversas.data não é um array ou está vazio", + ); return null; } - - const encontrada = conversas.data.find((c: { _id: string }) => c._id === conversaId); + + const encontrada = conversas.data.find( + (c: { _id: string }) => c._id === conversaId, + ); console.log("✅ [ChatWindow] Conversa encontrada:", encontrada); return encontrada; }); @@ -47,7 +63,10 @@ const c = conversa(); if (!c) return "Carregando..."; if (c.tipo === "grupo" || c.tipo === "sala_reuniao") { - return c.nome || (c.tipo === "sala_reuniao" ? "Sala sem nome" : "Grupo sem nome"); + return ( + c.nome || + (c.tipo === "sala_reuniao" ? "Sala sem nome" : "Grupo sem nome") + ); } return c.outroUsuario?.nome || "Usuário"; } @@ -64,10 +83,23 @@ return "👤"; } - function getStatusConversa(): "online" | "offline" | "ausente" | "externo" | "em_reuniao" | null { + function getStatusConversa(): + | "online" + | "offline" + | "ausente" + | "externo" + | "em_reuniao" + | null { const c = conversa(); if (c && c.tipo === "individual" && c.outroUsuario) { - return (c.outroUsuario.statusPresenca as "online" | "offline" | "ausente" | "externo" | "em_reuniao") || "offline"; + return ( + (c.outroUsuario.statusPresenca as + | "online" + | "offline" + | "ausente" + | "externo" + | "em_reuniao") || "offline" + ); } return null; } @@ -83,15 +115,19 @@ async function handleSairGrupoOuSala() { const c = conversa(); if (!c || (c.tipo !== "grupo" && c.tipo !== "sala_reuniao")) return; - + const tipoTexto = c.tipo === "sala_reuniao" ? "sala de reunião" : "grupo"; - if (!confirm(`Tem certeza que deseja sair da ${tipoTexto} "${c.nome || "Sem nome"}"?`)) { + if ( + !confirm( + `Tem certeza que deseja sair da ${tipoTexto} "${c.nome || "Sem nome"}"?`, + ) + ) { return; } try { const resultado = await client.mutation(api.chat.sairGrupoOuSala, { - conversaId: conversaId as any, + conversaId: conversaId as Id<"conversas">, }); if (resultado.sucesso) { @@ -99,16 +135,21 @@ } else { alert(resultado.erro || "Erro ao sair da conversa"); } - } catch (error: any) { + } catch (error) { console.error("Erro ao sair da conversa:", error); - alert(error.message || "Erro ao sair da conversa"); + const errorMessage = + error instanceof Error ? error.message : "Erro ao sair da conversa"; + alert(errorMessage); } }
(showAdminMenu = false)}> -
e.stopPropagation()}> +
e.stopPropagation()} + > -
+
{#if conversa() && conversa()?.tipo === "individual" && conversa()?.outroUsuario}
-

{getNomeConversa()}

+

+ {getNomeConversa()} +

{#if getStatusMensagem()} -

{getStatusMensagem()}

+

+ {getStatusMensagem()} +

{:else if getStatusConversa()}

{getStatusConversa() === "online" @@ -165,30 +207,54 @@ {:else if conversa() && (conversa()?.tipo === "grupo" || conversa()?.tipo === "sala_reuniao")}

- {conversa()?.participantesInfo?.length || 0} {conversa()?.participantesInfo?.length === 1 ? "participante" : "participantes"} + {conversa()?.participantesInfo?.length || 0} + {conversa()?.participantesInfo?.length === 1 + ? "participante" + : "participantes"}

{#if conversa()?.participantesInfo && conversa()?.participantesInfo.length > 0}
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)} -
+
{#if participante.fotoPerfilUrl} - {participante.nome} + {participante.nome} {:else if participante.avatar} - {participante.nome} + {participante.nome} {:else} - {participante.nome} + {participante.nome} {/if}
{/each} {#if conversa()?.participantesInfo.length > 5} -
+
+{conversa()?.participantesInfo.length - 5}
{/if}
{#if conversa()?.tipo === "sala_reuniao" && isAdmin?.data} - • Admin + • Admin {/if}
{/if} @@ -211,7 +277,9 @@ aria-label="Sair" title="Sair da conversa" > -
+
-
+
{ e.stopPropagation(); (async () => { - if (!confirm("Tem certeza que deseja encerrar esta reunião? Todos os participantes serão removidos.")) return; + if ( + !confirm( + "Tem certeza que deseja encerrar esta reunião? Todos os participantes serão removidos.", + ) + ) + return; try { - const resultado = await client.mutation(api.chat.encerrarReuniao, { - conversaId: conversaId as any, - }); + const resultado = await client.mutation( + api.chat.encerrarReuniao, + { + conversaId: conversaId as Id<"conversas">, + }, + ); if (resultado.sucesso) { alert("Reunião encerrada com sucesso!"); voltarParaLista(); } else { alert(resultado.erro || "Erro ao encerrar reunião"); } - } catch (error: any) { - alert(error.message || "Erro ao encerrar reunião"); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : "Erro ao encerrar reunião"; + alert(errorMessage); } showAdminMenu = false; })(); @@ -305,7 +387,7 @@ {/if}
{/if} - +
{/if} - diff --git a/apps/web/src/lib/components/chat/MessageInput.svelte b/apps/web/src/lib/components/chat/MessageInput.svelte index 8f35137..258e237 100644 --- a/apps/web/src/lib/components/chat/MessageInput.svelte +++ b/apps/web/src/lib/components/chat/MessageInput.svelte @@ -3,7 +3,6 @@ import { api } from "@sgse-app/backend/convex/_generated/api"; import type { Id } from "@sgse-app/backend/convex/_generated/dataModel"; import { onMount } from "svelte"; - import { authStore } from "$lib/stores/auth.svelte"; import { Paperclip, Smile, Send } from "lucide-svelte"; interface Props { @@ -35,18 +34,67 @@ let uploadingFile = $state(false); let digitacaoTimeout: ReturnType | null = null; let showEmojiPicker = $state(false); - let mensagemRespondendo: { id: Id<"mensagens">; conteudo: string; remetente: string } | null = $state(null); + let mensagemRespondendo: { + id: Id<"mensagens">; + conteudo: string; + remetente: string; + } | null = $state(null); let showMentionsDropdown = $state(false); let mentionQuery = $state(""); let mentionStartPos = $state(0); // Emojis mais usados const emojis = [ - "😀", "😃", "😄", "😁", "😅", "😂", "🤣", "😊", "😇", "🙂", - "🙃", "😉", "😌", "😍", "🥰", "😘", "😗", "😙", "😚", "😋", - "😛", "😝", "😜", "🤪", "🤨", "🧐", "🤓", "😎", "🥳", "😏", - "👍", "👎", "👏", "🙌", "🤝", "🙏", "💪", "✨", "🎉", "🎊", - "❤️", "💙", "💚", "💛", "🧡", "💜", "🖤", "🤍", "💯", "🔥", + "😀", + "😃", + "😄", + "😁", + "😅", + "😂", + "🤣", + "😊", + "😇", + "🙂", + "🙃", + "😉", + "😌", + "😍", + "🥰", + "😘", + "😗", + "😙", + "😚", + "😋", + "😛", + "😝", + "😜", + "🤪", + "🤨", + "🧐", + "🤓", + "😎", + "🥳", + "😏", + "👍", + "👎", + "👏", + "🙌", + "🤝", + "🙏", + "💪", + "✨", + "🎉", + "🎊", + "❤️", + "💙", + "💚", + "💛", + "🧡", + "💜", + "🖤", + "🤍", + "💯", + "🔥", ]; function adicionarEmoji(emoji: string) { @@ -60,7 +108,11 @@ // Obter conversa atual const conversa = $derived((): ConversaComParticipantes | null => { if (!conversas?.data) return null; - return (conversas.data as ConversaComParticipantes[]).find((c) => c._id === conversaId) || null; + return ( + (conversas.data as ConversaComParticipantes[]).find( + (c) => c._id === conversaId, + ) || null + ); }); // Obter participantes para menções (apenas grupos e salas) @@ -74,10 +126,13 @@ const participantesFiltrados = $derived((): ParticipanteInfo[] => { if (!mentionQuery.trim()) return participantesParaMencoes().slice(0, 5); const query = mentionQuery.toLowerCase(); - return participantesParaMencoes().filter((p) => - p.nome?.toLowerCase().includes(query) || - (p.email && p.email.toLowerCase().includes(query)) - ).slice(0, 5); + return participantesParaMencoes() + .filter( + (p) => + p.nome?.toLowerCase().includes(query) || + (p.email && p.email.toLowerCase().includes(query)), + ) + .slice(0, 5); }); // Auto-resize do textarea e detectar menções @@ -91,19 +146,19 @@ // Detectar menções (@) const cursorPos = target.selectionStart || 0; const textBeforeCursor = mensagem.substring(0, cursorPos); - const lastAtIndex = textBeforeCursor.lastIndexOf('@'); - + const lastAtIndex = textBeforeCursor.lastIndexOf("@"); + if (lastAtIndex !== -1) { const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1); // Se não há espaço após o @, mostrar dropdown - if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { + if (!textAfterAt.includes(" ") && !textAfterAt.includes("\n")) { mentionQuery = textAfterAt; mentionStartPos = lastAtIndex; showMentionsDropdown = true; return; } } - + showMentionsDropdown = false; // Indicador de digitação (debounce de 1s) @@ -118,9 +173,11 @@ } function inserirMencao(participante: ParticipanteInfo) { - const nome = participante.nome.split(' ')[0]; // Usar primeiro nome + const nome = participante.nome.split(" ")[0]; // Usar primeiro nome const antes = mensagem.substring(0, mentionStartPos); - const depois = mensagem.substring(textarea.selectionStart || mensagem.length); + const depois = mensagem.substring( + textarea.selectionStart || mensagem.length, + ); mensagem = antes + `@${nome} ` + depois; showMentionsDropdown = false; mentionQuery = ""; @@ -143,8 +200,9 @@ let match; while ((match = mentionRegex.exec(texto)) !== null) { const nomeMencionado = match[1]; - const participante = participantesParaMencoes().find((p) => - p.nome.split(' ')[0].toLowerCase() === nomeMencionado.toLowerCase() + const participante = participantesParaMencoes().find( + (p) => + p.nome.split(" ")[0].toLowerCase() === nomeMencionado.toLowerCase(), ); if (participante) { mencoesIds.push(participante._id); @@ -168,9 +226,12 @@ respostaPara: mensagemRespondendo?.id, mencoes: mencoesIds.length > 0 ? mencoesIds : undefined, }); - - console.log("✅ [MessageInput] Mensagem enviada com sucesso! ID:", result); - + + console.log( + "✅ [MessageInput] Mensagem enviada com sucesso! ID:", + result, + ); + mensagem = ""; mensagemRespondendo = null; showMentionsDropdown = false; @@ -201,17 +262,21 @@ const handler = (e: Event) => { const customEvent = e as CustomEvent<{ mensagemId: Id<"mensagens"> }>; // Buscar informações da mensagem para exibir preview - client.query(api.chat.obterMensagens, { conversaId, limit: 100 }).then((mensagens) => { - const msg = (mensagens as MensagemComRemetente[]).find((m) => m._id === customEvent.detail.mensagemId); - if (msg) { - mensagemRespondendo = { - id: msg._id, - conteudo: msg.conteudo.substring(0, 100), - remetente: msg.remetente?.nome || "Usuário", - }; - textarea?.focus(); - } - }); + client + .query(api.chat.obterMensagens, { conversaId, limit: 100 }) + .then((mensagens) => { + const msg = (mensagens as MensagemComRemetente[]).find( + (m) => m._id === customEvent.detail.mensagemId, + ); + if (msg) { + mensagemRespondendo = { + id: msg._id, + conteudo: msg.conteudo.substring(0, 100), + remetente: msg.remetente?.nome || "Usuário", + }; + textarea?.focus(); + } + }); }; window.addEventListener("responderMensagem", handler); @@ -236,7 +301,7 @@ return; } } - + // Enter sem Shift = enviar if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -259,7 +324,9 @@ uploadingFile = true; // 1. Obter upload URL - const uploadUrl = await client.mutation(api.chat.uploadArquivoChat, { conversaId }); + const uploadUrl = await client.mutation(api.chat.uploadArquivoChat, { + conversaId, + }); // 2. Upload do arquivo const result = await fetch(uploadUrl, { @@ -275,7 +342,9 @@ const { storageId } = await result.json(); // 3. Enviar mensagem com o arquivo - const tipo: "imagem" | "arquivo" = file.type.startsWith("image/") ? "imagem" : "arquivo"; + const tipo: "imagem" | "arquivo" = file.type.startsWith("image/") + ? "imagem" + : "arquivo"; await client.mutation(api.chat.enviarMensagem, { conversaId, conteudo: tipo === "imagem" ? "" : file.name, @@ -306,10 +375,16 @@
{#if mensagemRespondendo} -
+
-

Respondendo a {mensagemRespondendo.remetente}

-

{mensagemRespondendo.conteudo}

+

+ Respondendo a {mensagemRespondendo.remetente} +

+

+ {mensagemRespondendo.conteudo} +

{/each} @@ -429,15 +520,19 @@
- diff --git a/apps/web/src/lib/components/chat/MessageList.svelte b/apps/web/src/lib/components/chat/MessageList.svelte index 84e7d8c..2311f8b 100644 --- a/apps/web/src/lib/components/chat/MessageList.svelte +++ b/apps/web/src/lib/components/chat/MessageList.svelte @@ -5,7 +5,6 @@ import { format } from "date-fns"; import { ptBR } from "date-fns/locale"; import { onMount, tick } from "svelte"; - import { authStore } from "$lib/stores/auth.svelte"; interface Props { conversaId: Id<"conversas">; @@ -14,17 +13,25 @@ let { conversaId }: Props = $props(); const client = useConvexClient(); - const mensagens = useQuery(api.chat.obterMensagens, { conversaId, limit: 50 }); + const mensagens = useQuery(api.chat.obterMensagens, { + conversaId, + limit: 50, + }); const digitando = useQuery(api.chat.obterDigitando, { conversaId }); const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId }); const conversas = useQuery(api.chat.listarConversas, {}); + // Usuário atual + const currentUser = useQuery(api.auth.getCurrentUser, {}); let messagesContainer: HTMLDivElement; let shouldScrollToBottom = true; let lastMessageCount = 0; let mensagensNotificadas = $state>(new Set()); let showNotificationPopup = $state(false); - let notificationMessage = $state<{ remetente: string; conteudo: string } | null>(null); + let notificationMessage = $state<{ + remetente: string; + conteudo: string; + } | null>(null); let notificationTimeout: ReturnType | null = null; let mensagensCarregadas = $state(false); @@ -33,8 +40,8 @@ // Carregar mensagens já notificadas do localStorage ao montar $effect(() => { - if (typeof window !== 'undefined' && !mensagensCarregadas) { - const saved = localStorage.getItem('chat-mensagens-notificadas'); + if (typeof window !== "undefined" && !mensagensCarregadas) { + const saved = localStorage.getItem("chat-mensagens-notificadas"); if (saved) { try { const ids = JSON.parse(saved) as string[]; @@ -44,7 +51,7 @@ } } mensagensCarregadas = true; - + // Marcar todas as mensagens atuais como já visualizadas (não tocar beep ao abrir) if (mensagens?.data && mensagens.data.length > 0) { mensagens.data.forEach((msg) => { @@ -57,17 +64,20 @@ // Salvar mensagens notificadas no localStorage function salvarMensagensNotificadas() { - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { const ids = Array.from(mensagensNotificadas); // Limitar a 1000 IDs para não encher o localStorage const idsLimitados = ids.slice(-1000); - localStorage.setItem('chat-mensagens-notificadas', JSON.stringify(idsLimitados)); + localStorage.setItem( + "chat-mensagens-notificadas", + JSON.stringify(idsLimitados), + ); } } - // Atualizar usuarioAtualId sempre que authStore.usuario mudar + // Atualizar usuarioAtualId sempre que currentUser mudar $effect(() => { - const usuario = authStore.usuario; + const usuario = currentUser?.data; if (usuario?._id) { const idStr = String(usuario._id).trim(); usuarioAtualId = idStr || null; @@ -80,11 +90,14 @@ function tocarSomNotificacao() { try { // Usar AudioContext (requer interação do usuário para iniciar) - const AudioContextClass = window.AudioContext || (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + const AudioContextClass = + window.AudioContext || + (window as { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext; if (!AudioContextClass) return; - + let audioContext: AudioContext | null = null; - + try { audioContext = new AudioContext(); } catch (e) { @@ -92,40 +105,49 @@ console.warn("Não foi possível criar AudioContext:", e); return; } - + // Resumir contexto se estiver suspenso (necessário após interação do usuário) - if (audioContext.state === 'suspended') { - audioContext.resume().then(() => { - const oscillator = audioContext!.createOscillator(); - const gainNode = audioContext!.createGain(); - - oscillator.connect(gainNode); - gainNode.connect(audioContext!.destination); - - oscillator.frequency.value = 800; - oscillator.type = 'sine'; - - gainNode.gain.setValueAtTime(0.2, audioContext!.currentTime); - gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext!.currentTime + 0.3); - - oscillator.start(audioContext!.currentTime); - oscillator.stop(audioContext!.currentTime + 0.3); - }).catch(() => { - // Ignorar erro se não conseguir resumir - }); + if (audioContext.state === "suspended") { + audioContext + .resume() + .then(() => { + const oscillator = audioContext!.createOscillator(); + const gainNode = audioContext!.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext!.destination); + + oscillator.frequency.value = 800; + oscillator.type = "sine"; + + gainNode.gain.setValueAtTime(0.2, audioContext!.currentTime); + gainNode.gain.exponentialRampToValueAtTime( + 0.01, + audioContext!.currentTime + 0.3, + ); + + oscillator.start(audioContext!.currentTime); + oscillator.stop(audioContext!.currentTime + 0.3); + }) + .catch(() => { + // Ignorar erro se não conseguir resumir + }); } else { const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); - + oscillator.connect(gainNode); gainNode.connect(audioContext.destination); - + oscillator.frequency.value = 800; - oscillator.type = 'sine'; - + oscillator.type = "sine"; + gainNode.gain.setValueAtTime(0.2, audioContext.currentTime); - gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3); - + gainNode.gain.exponentialRampToValueAtTime( + 0.01, + audioContext.currentTime + 0.3, + ); + oscillator.start(audioContext.currentTime); oscillator.stop(audioContext.currentTime + 0.3); } @@ -140,31 +162,39 @@ if (mensagens?.data && messagesContainer) { const currentCount = mensagens.data.length; const isNewMessage = currentCount > lastMessageCount; - + // Detectar nova mensagem de outro usuário if (isNewMessage && mensagens.data.length > 0 && usuarioAtualId) { const ultimaMensagem = mensagens.data[mensagens.data.length - 1]; const mensagemId = String(ultimaMensagem._id); - const remetenteIdStr = ultimaMensagem.remetenteId - ? String(ultimaMensagem.remetenteId).trim() - : (ultimaMensagem.remetente?._id ? String(ultimaMensagem.remetente._id).trim() : null); - + const remetenteIdStr = ultimaMensagem.remetenteId + ? String(ultimaMensagem.remetenteId).trim() + : ultimaMensagem.remetente?._id + ? String(ultimaMensagem.remetente._id).trim() + : null; + // Se é uma nova mensagem de outro usuário (não minha) E ainda não foi notificada - if (remetenteIdStr && remetenteIdStr !== usuarioAtualId && !mensagensNotificadas.has(mensagemId)) { + if ( + remetenteIdStr && + remetenteIdStr !== usuarioAtualId && + !mensagensNotificadas.has(mensagemId) + ) { // Marcar como notificada antes de tocar som (evita duplicação) mensagensNotificadas.add(mensagemId); salvarMensagensNotificadas(); - + // Tocar som de notificação (apenas uma vez) tocarSomNotificacao(); - + // Mostrar popup de notificação notificationMessage = { remetente: ultimaMensagem.remetente?.nome || "Usuário", - conteudo: ultimaMensagem.conteudo.substring(0, 100) + (ultimaMensagem.conteudo.length > 100 ? "..." : "") + conteudo: + ultimaMensagem.conteudo.substring(0, 100) + + (ultimaMensagem.conteudo.length > 100 ? "..." : ""), }; showNotificationPopup = true; - + // Ocultar popup após 5 segundos if (notificationTimeout) { clearTimeout(notificationTimeout); @@ -175,7 +205,7 @@ }, 5000); } } - + if (isNewMessage || shouldScrollToBottom) { // Usar requestAnimationFrame para garantir que o DOM foi atualizado requestAnimationFrame(() => { @@ -186,7 +216,7 @@ }); }); } - + lastMessageCount = currentCount; } }); @@ -195,9 +225,11 @@ $effect(() => { if (mensagens?.data && mensagens.data.length > 0 && usuarioAtualId) { const ultimaMensagem = mensagens.data[mensagens.data.length - 1]; - const remetenteIdStr = ultimaMensagem.remetenteId - ? String(ultimaMensagem.remetenteId).trim() - : (ultimaMensagem.remetente?._id ? String(ultimaMensagem.remetente._id).trim() : null); + const remetenteIdStr = ultimaMensagem.remetenteId + ? String(ultimaMensagem.remetenteId).trim() + : ultimaMensagem.remetente?._id + ? String(ultimaMensagem.remetente._id).trim() + : null; // Só marcar como lida se não for minha mensagem if (remetenteIdStr && remetenteIdStr !== usuarioAtualId) { client.mutation(api.chat.marcarComoLida, { @@ -265,7 +297,9 @@ lidaPor?: Id<"usuarios">[]; // IDs dos usuários que leram a mensagem } - function agruparMensagensPorDia(msgs: Mensagem[]): Record { + function agruparMensagensPorDia( + msgs: Mensagem[], + ): Record { const grupos: Record = {}; for (const msg of msgs) { const dia = formatarDiaMensagem(msg.enviadaEm); @@ -291,7 +325,9 @@ }); } - function getEmojisReacao(mensagem: Mensagem): Array<{ emoji: string; count: number }> { + function getEmojisReacao( + mensagem: Mensagem, + ): Array<{ emoji: string; count: number }> { if (!mensagem.reagiuPor || mensagem.reagiuPor.length === 0) return []; const emojiMap: Record = {}; @@ -336,27 +372,33 @@ novoConteudoEditado = ""; } - async function deletarMensagem(mensagemId: Id<"mensagens">, isAdminDeleting: boolean = false) { - const mensagemTexto = isAdminDeleting + async function deletarMensagem( + mensagemId: Id<"mensagens">, + isAdminDeleting: boolean = false, + ) { + const mensagemTexto = isAdminDeleting ? "Tem certeza que deseja deletar esta mensagem como administrador? O remetente será notificado." : "Tem certeza que deseja deletar esta mensagem?"; - + if (!confirm(mensagemTexto)) { return; } try { if (isAdminDeleting) { - const resultado = await client.mutation(api.chat.deletarMensagemComoAdmin, { - mensagemId, - }); + const resultado = await client.mutation( + api.chat.deletarMensagemComoAdmin, + { + mensagemId, + }, + ); if (!resultado.sucesso) { alert(resultado.erro || "Erro ao deletar mensagem"); } } else { - await client.mutation(api.chat.deletarMensagem, { - mensagemId, - }); + await client.mutation(api.chat.deletarMensagem, { + mensagemId, + }); } } catch (error) { console.error("Erro ao deletar mensagem:", error); @@ -393,7 +435,7 @@ // Para conversas individuais: verificar se o outro participante leu if (conversa.tipo === "individual") { const outroParticipante = conversa.participantes?.find( - (p: any) => String(p) !== usuarioAtualId + (p: any) => String(p) !== usuarioAtualId, ); if (outroParticipante) { return lidaPorStr.includes(String(outroParticipante)); @@ -402,13 +444,16 @@ // Para grupos/salas: verificar se pelo menos um outro participante leu if (conversa.tipo === "grupo" || conversa.tipo === "sala_reuniao") { - const outrosParticipantes = conversa.participantes?.filter( - (p: any) => String(p) !== usuarioAtualId && String(p) !== String(mensagem.remetenteId) - ) || []; + const outrosParticipantes = + conversa.participantes?.filter( + (p: any) => + String(p) !== usuarioAtualId && + String(p) !== String(mensagem.remetenteId), + ) || []; if (outrosParticipantes.length === 0) return false; // Verificar se pelo menos um outro participante leu return outrosParticipantes.some((p: any) => - lidaPorStr.includes(String(p)) + lidaPorStr.includes(String(p)), ); } @@ -426,7 +471,9 @@ {#each Object.entries(gruposPorDia) as [dia, mensagensDia]}
-
+
{dia}
@@ -444,14 +491,17 @@ } return null; })()} - {@const isMinha = usuarioAtualId && remetenteIdStr && remetenteIdStr === usuarioAtualId} -
-
+ {@const isMinha = + usuarioAtualId && remetenteIdStr && remetenteIdStr === usuarioAtualId} +
+
{#if isMinha} -

- Você -

+

Você

{:else}

{mensagem.remetente?.nome || "Usuário"} @@ -468,13 +518,15 @@ > {#if mensagem.mensagemOriginal} -

+

{mensagem.mensagemOriginal.remetente?.nome || "Usuário"}

- {mensagem.mensagemOriginal.deletada - ? "Mensagem deletada" + {mensagem.mensagemOriginal.deletada + ? "Mensagem deletada" : mensagem.mensagemOriginal.conteudo}

@@ -514,12 +566,16 @@ {:else if mensagem.tipo === "texto"} {#if mensagem.conteudo} -

{mensagem.conteudo}

+

+ {mensagem.conteudo} +

{/if} {:else if mensagem.tipo === "arquivo"} handleReagir(mensagem._id, reacao.emoji)} > - {reacao.emoji} {reacao.count} + {reacao.emoji} + {reacao.count} {/each}
@@ -622,91 +688,91 @@ {/if}
- -
-

- {formatarDataMensagem(mensagem.enviadaEm)} -

- {#if isMinha && !mensagem.deletada && !mensagem.agendadaPara} - -
- {#if mensagemFoiLida(mensagem)} - - - - - - - - {:else} - - - - - {/if} -
- {/if} - {#if !mensagem.deletada && !mensagem.agendadaPara} -
- {#if isMinha} - - - - {:else if isAdmin?.data} - - - {/if} -
- {/if} -
+ +
+

+ {formatarDataMensagem(mensagem.enviadaEm)} +

+ {#if isMinha && !mensagem.deletada && !mensagem.agendadaPara} + +
+ {#if mensagemFoiLida(mensagem)} + + + + + + + + {:else} + + + + + {/if} +
+ {/if} + {#if !mensagem.deletada && !mensagem.agendadaPara} +
+ {#if isMinha} + + + + {:else if isAdmin?.data} + + + {/if} +
+ {/if} +
{/each} @@ -716,7 +782,9 @@ {#if digitando?.data && digitando.data.length > 0}
-
+

- {digitando.data.map((u: { nome: string }) => u.nome).join(", ")} {digitando.data.length === 1 + {digitando.data.map((u: { nome: string }) => u.nome).join(", ")} + {digitando.data.length === 1 ? "está digitando" : "estão digitando"}...

@@ -756,7 +825,9 @@ />

Nenhuma mensagem ainda

-

Envie a primeira mensagem!

+

+ Envie a primeira mensagem! +

{/if}
@@ -775,7 +846,9 @@ }} >
-
+
- +
-

Nova mensagem de {notificationMessage.remetente}

-

{notificationMessage.conteudo}

+

+ Nova mensagem de {notificationMessage.remetente} +

+

+ {notificationMessage.conteudo} +

{/if} - diff --git a/apps/web/src/lib/components/chat/NewConversationModal.svelte b/apps/web/src/lib/components/chat/NewConversationModal.svelte index 53b9d1c..3489113 100644 --- a/apps/web/src/lib/components/chat/NewConversationModal.svelte +++ b/apps/web/src/lib/components/chat/NewConversationModal.svelte @@ -2,10 +2,19 @@ import { useQuery, useConvexClient } from "convex-svelte"; import { api } from "@sgse-app/backend/convex/_generated/api"; import { abrirConversa } from "$lib/stores/chatStore"; - import { authStore } from "$lib/stores/auth.svelte"; import UserStatusBadge from "./UserStatusBadge.svelte"; import UserAvatar from "./UserAvatar.svelte"; - import { MessageSquare, User, Users, Video, X, Search, ChevronRight, Plus, UserX } from "lucide-svelte"; + import { + MessageSquare, + User, + Users, + Video, + X, + Search, + ChevronRight, + Plus, + UserX, + } from "lucide-svelte"; interface Props { onClose: () => void; @@ -16,6 +25,8 @@ const client = useConvexClient(); const usuarios = useQuery(api.usuarios.listarParaChat, {}); const meuPerfil = useQuery(api.usuarios.obterPerfil, {}); + // Usuário atual + const currentUser = useQuery(api.auth.getCurrentUser, {}); let activeTab = $state<"individual" | "grupo" | "sala_reuniao">("individual"); let searchQuery = $state(""); @@ -26,27 +37,36 @@ const usuariosFiltrados = $derived(() => { if (!usuarios?.data) return []; - + // Filtrar o próprio usuário - const meuId = authStore.usuario?._id || meuPerfil?.data?._id; + const meuId = currentUser?.data?._id || meuPerfil?.data?._id; let lista = usuarios.data.filter((u: any) => u._id !== meuId); - + // Aplicar busca if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); - lista = lista.filter((u: any) => - u.nome?.toLowerCase().includes(query) || - u.email?.toLowerCase().includes(query) || - u.matricula?.toLowerCase().includes(query) + lista = lista.filter( + (u: any) => + u.nome?.toLowerCase().includes(query) || + u.email?.toLowerCase().includes(query) || + u.matricula?.toLowerCase().includes(query), ); } - + // Ordenar: online primeiro, depois por nome return lista.sort((a: any, b: any) => { - const statusOrder = { online: 0, ausente: 1, externo: 2, em_reuniao: 3, offline: 4 }; - const statusA = statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4; - const statusB = statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4; - + const statusOrder = { + online: 0, + ausente: 1, + externo: 2, + em_reuniao: 3, + offline: 4, + }; + const statusA = + statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4; + const statusB = + statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4; + if (statusA !== statusB) return statusA - statusB; return (a.nome || "").localeCompare(b.nome || ""); }); @@ -99,7 +119,8 @@ onClose(); } catch (error: any) { console.error("Erro ao criar grupo:", error); - const mensagem = error?.message || error?.data || "Erro desconhecido ao criar grupo"; + const mensagem = + error?.message || error?.data || "Erro desconhecido ao criar grupo"; alert(`Erro ao criar grupo: ${mensagem}`); } finally { loading = false; @@ -127,7 +148,10 @@ onClose(); } catch (error: any) { console.error("Erro ao criar sala de reunião:", error); - const mensagem = error?.message || error?.data || "Erro desconhecido ao criar sala de reunião"; + const mensagem = + error?.message || + error?.data || + "Erro desconhecido ao criar sala de reunião"; alert(`Erro ao criar sala de reunião: ${mensagem}`); } finally { loading = false; @@ -135,10 +159,18 @@ } - e.target === e.currentTarget && onClose()}> -