Merge pull request #39 from killer-cf/call-audio-video-jitsi

Call audio video jitsi
This commit is contained in:
2025-11-22 18:40:54 -03:00
committed by GitHub
21 changed files with 2706 additions and 114 deletions

View File

@@ -5,6 +5,49 @@
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<!-- Polyfill BlobBuilder ANTES de qualquer código JavaScript -->
<!-- IMPORTANTE: Este script DEVE ser executado antes de qualquer módulo JavaScript -->
<script>
// Executar IMEDIATAMENTE, de forma síncrona e bloqueante
// Não usar IIFE assíncrona, executar direto no escopo global
if (typeof window !== 'undefined') {
// Verificar se já foi adicionado
if (!window.__blobBuilderPolyfillAdded) {
// Implementar BlobBuilder usando Blob moderno
function BlobBuilder() {
this.parts = [];
}
BlobBuilder.prototype.append = function(data) {
this.parts.push(data);
};
BlobBuilder.prototype.getBlob = function(contentType) {
return new Blob(this.parts, contentType ? { type: contentType } : undefined);
};
// Adicionar em TODOS os locais possíveis, SEM verificação
// (sobrescrever se necessário para garantir que esteja disponível)
window.BlobBuilder = BlobBuilder;
window.WebKitBlobBuilder = BlobBuilder;
window.MozBlobBuilder = BlobBuilder;
window.MSBlobBuilder = BlobBuilder;
// Adicionar no globalThis também
if (typeof globalThis !== 'undefined') {
globalThis.BlobBuilder = BlobBuilder;
globalThis.WebKitBlobBuilder = BlobBuilder;
globalThis.MozBlobBuilder = BlobBuilder;
}
// Marcar como adicionado
window.__blobBuilderPolyfillAdded = true;
// Log apenas se console está disponível
if (console && console.log) {
console.log('✅ Polyfill BlobBuilder adicionado globalmente (via app.html)');
}
}
}
</script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>

View File

@@ -153,12 +153,13 @@
const chamadaQuery = useQuery(api.chamadas.obterChamada, { chamadaId });
const chamada = $derived(chamadaQuery?.data);
const meuPerfil = useQuery(api.auth.getCurrentUser, {});
const configJitsiBackend = useQuery(api.configuracaoJitsi.obterConfigJitsi, {});
// Estado derivado do store
const estadoChamada = $derived(get(callState));
// Configuração Jitsi
const configJitsi = $derived.by(() => obterConfiguracaoJitsi());
// Configuração Jitsi (busca do backend primeiro, depois fallback para env vars)
const configJitsi = $derived.by(() => obterConfiguracaoJitsi(configJitsiBackend?.data || null));
// Handler de erro
function handleError(message: string, details?: string): void {
@@ -175,8 +176,68 @@
if (!browser || JitsiMeetJS) return;
try {
console.log('🔄 Tentando carregar lib-jitsi-meet...');
// Polyfill BlobBuilder já deve estar disponível via app.html
// Verificar se está disponível antes de carregar a biblioteca
if (typeof (window as any).BlobBuilder === 'undefined') {
console.warn('⚠️ Polyfill BlobBuilder não encontrado, pode causar erros');
}
// Tentar carregar o módulo lib-jitsi-meet dinamicamente
// Usar import dinâmico para evitar problemas de SSR e permitir carregamento apenas no browser
const module = await import('lib-jitsi-meet');
JitsiMeetJS = module.default as unknown as JitsiMeetJSLib;
console.log('📦 Módulo carregado, verificando exportações...', {
hasDefault: !!module.default,
hasJitsiMeetJS: !!module.JitsiMeetJS,
keys: Object.keys(module)
});
// Tentar múltiplas formas de acessar o JitsiMeetJS
// A biblioteca pode exportar de diferentes formas dependendo da versão
let jitsiModule: unknown = null;
// Tentativa 1: export default
if (module.default) {
if (typeof module.default === 'object' && 'init' in module.default) {
jitsiModule = module.default;
console.log('✅ Encontrado em module.default');
}
}
// Tentativa 2: export nomeado JitsiMeetJS
if (!jitsiModule && module.JitsiMeetJS) {
jitsiModule = module.JitsiMeetJS;
console.log('✅ Encontrado em module.JitsiMeetJS');
}
// Tentativa 3: o próprio módulo pode ser o JitsiMeetJS
if (!jitsiModule && typeof module === 'object' && 'init' in module) {
jitsiModule = module;
console.log('✅ Encontrado no próprio módulo');
}
if (!jitsiModule) {
throw new Error(
'Não foi possível encontrar JitsiMeetJS no módulo. ' +
'Verifique se lib-jitsi-meet está instalado corretamente.'
);
}
JitsiMeetJS = jitsiModule as unknown as JitsiMeetJSLib;
// Verificar se JitsiMeetJS foi inicializado corretamente
if (!JitsiMeetJS || !JitsiMeetJS.init || typeof JitsiMeetJS.init !== 'function') {
throw new Error('JitsiMeetJS não possui método init válido');
}
// Verificar se JitsiConnection existe
if (!JitsiMeetJS.JitsiConnection) {
throw new Error('JitsiConnection não está disponível no módulo carregado');
}
console.log('🔧 Inicializando Jitsi Meet JS...');
// Inicializar Jitsi
JitsiMeetJS.init({
@@ -188,16 +249,35 @@
disableThirdPartyRequests: false
});
// Configurar nível de log para DEBUG em desenvolvimento
JitsiMeetJS.setLogLevel(JitsiMeetJS.constants.logLevels.INFO);
// Configurar nível de log
if (JitsiMeetJS.setLogLevel && typeof JitsiMeetJS.setLogLevel === 'function') {
if (JitsiMeetJS.constants && JitsiMeetJS.constants.logLevels) {
JitsiMeetJS.setLogLevel(JitsiMeetJS.constants.logLevels.INFO);
}
}
console.log('✅ Jitsi Meet JS carregado e inicializado');
} catch (error) {
console.error('Erro ao carregar lib-jitsi-meet:', error);
handleError(
'Erro ao carregar biblioteca de vídeo',
'Não foi possível carregar a biblioteca necessária para chamadas de vídeo. Por favor, recarregue a página.'
);
console.log('✅ Jitsi Meet JS carregado e inicializado com sucesso');
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('❌ Erro ao carregar lib-jitsi-meet:', error);
console.error('Detalhes do erro:', {
message: errorMessage,
stack: error instanceof Error ? error.stack : undefined,
error
});
// Verificar se é um erro de módulo não encontrado
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Cannot find module')) {
handleError(
'Biblioteca de vídeo não encontrada',
'A biblioteca Jitsi não pôde ser encontrada. Verifique se o pacote "lib-jitsi-meet" está instalado. Se o problema persistir, tente limpar o cache do navegador e recarregar a página.'
);
} else {
handleError(
'Erro ao carregar biblioteca de vídeo',
`Não foi possível carregar a biblioteca necessária para chamadas de vídeo. Erro: ${errorMessage}. Por favor, recarregue a página e tente novamente.`
);
}
}
}
@@ -855,6 +935,12 @@
onMount(async () => {
if (!browser) return;
// Polyfill BlobBuilder já deve estar disponível via app.html
// Verificar se está disponível
if (typeof (window as any).BlobBuilder === 'undefined') {
console.warn('⚠️ Polyfill BlobBuilder não encontrado no onMount');
}
// Inicializar store primeiro
inicializarStore();

View File

@@ -190,9 +190,51 @@
const informacoesDispositivo = await obterInformacoesDispositivo();
coletandoInfo = false;
// Obter tempo sincronizado
const timestamp = await obterTempoServidor(client);
const sincronizadoComServidor = true; // Sempre true quando usamos obterTempoServidor
// Obter tempo sincronizado e aplicar GMT offset (igual ao relógio)
const configRelogio = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
// Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido
const gmtOffset = configRelogio.gmtOffset ?? 0;
let timestampBase: number;
if (configRelogio.usarServidorExterno) {
try {
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
if (resultado.sucesso && resultado.timestamp) {
timestampBase = resultado.timestamp;
} else {
timestampBase = await obterTempoServidor(client);
}
} catch (error) {
console.warn('Erro ao sincronizar com servidor externo:', error);
if (configRelogio.fallbackParaPC) {
timestampBase = Date.now();
} else {
timestampBase = await obterTempoServidor(client);
}
}
} else {
// Usar relógio do PC (sem sincronização com servidor)
timestampBase = Date.now();
}
// Aplicar GMT offset ao timestamp
// Quando GMT é 0, compensar o timezone local do navegador para que o timestamp
// represente o horário local (não UTC), evitando que apareça 3 horas a mais
let timestamp: number;
if (gmtOffset !== 0) {
// Aplicar offset configurado
timestamp = timestampBase + (gmtOffset * 60 * 60 * 1000);
} else {
// Quando GMT = 0, ajustar para horário local do navegador
// getTimezoneOffset() retorna minutos POSITIVOS para fusos ATRÁS de UTC
// Exemplo: Brasil (UTC-3) retorna 180 minutos
// Subtrair esses minutos para que o timestamp represente o horário local
const timezoneOffset = new Date().getTimezoneOffset(); // Offset em minutos
timestamp = timestampBase - (timezoneOffset * 60 * 1000); // Subtrair minutos em milissegundos
}
// Sincronizado apenas se usar servidor externo e sincronização foi bem-sucedida
const sincronizadoComServidor = configRelogio.usarServidorExterno && timestampBase !== Date.now();
// Upload da imagem (obrigatória agora)
let imagemId: Id<'_storage'> | undefined = undefined;
@@ -275,9 +317,47 @@
// Se capturou a foto, mostrar modal de confirmação
if (blob && capturandoAutomaticamente) {
capturandoAutomaticamente = false;
// Obter data e hora sincronizada do servidor
// Obter data e hora sincronizada do servidor com GMT offset (igual ao relógio)
try {
const timestamp = await obterTempoServidor(client);
const configRelogio = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
// Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido
const gmtOffset = configRelogio.gmtOffset ?? 0;
let timestampBase: number;
if (configRelogio.usarServidorExterno) {
try {
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
if (resultado.sucesso && resultado.timestamp) {
timestampBase = resultado.timestamp;
} else {
timestampBase = await obterTempoServidor(client);
}
} catch (error) {
console.warn('Erro ao sincronizar com servidor externo:', error);
if (configRelogio.fallbackParaPC) {
timestampBase = Date.now();
} else {
timestampBase = await obterTempoServidor(client);
}
}
} else {
// Usar relógio do PC (sem sincronização com servidor)
timestampBase = Date.now();
}
// Aplicar GMT offset ao timestamp
// Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática
// Quando GMT ≠ 0, aplicar offset configurado ao timestamp
let timestamp: number;
if (gmtOffset !== 0) {
// Aplicar offset configurado
timestamp = timestampBase + (gmtOffset * 60 * 60 * 1000);
} else {
// Quando GMT = 0, manter timestamp UTC puro
// O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
timestamp = timestampBase;
}
const dataObj = new Date(timestamp);
const data = dataObj.toLocaleDateString('pt-BR');
const hora = dataObj.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });

View File

@@ -17,6 +17,8 @@
async function atualizarTempo() {
try {
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
// Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido
// Se não estiver configurado, usar null e tratar como 0
const gmtOffset = config.gmtOffset ?? 0;
let timestampBase: number;
@@ -45,16 +47,25 @@
}
}
} else {
// Usar tempo do servidor Convex
timestampBase = await obterTempoServidor(client);
sincronizado = true;
// Usar relógio do PC (sem sincronização com servidor)
timestampBase = obterTempoPC();
sincronizado = false;
usandoServidorExterno = false;
erro = null;
erro = 'Usando relógio do PC';
}
// Aplicar GMT offset ao timestamp
// O timestamp está em UTC, adicionar o offset em horas
const timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000);
// Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática
// Quando GMT ≠ 0, aplicar offset configurado ao timestamp
let timestampAjustado: number;
if (gmtOffset !== 0) {
// Aplicar offset configurado
timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000);
} else {
// Quando GMT = 0, manter timestamp UTC puro
// O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
timestampAjustado = timestampBase;
}
tempoAtual = new Date(timestampAjustado);
} catch (error) {
console.error('Erro ao obter tempo:', error);
@@ -120,7 +131,7 @@
<span class="text-warning">{erro}</span>
{:else}
<Clock class="h-4 w-4 text-base-content/50" />
<span class="text-base-content/50">Sincronizando...</span>
<span class="text-base-content/50">Usando relógio do PC</span>
{/if}
</div>
</div>

View File

@@ -7,6 +7,7 @@ export interface ConfiguracaoJitsi {
appId: string;
roomPrefix: string;
useHttps: boolean;
acceptSelfSignedCert?: boolean;
}
export interface DispositivoMedia {
@@ -22,9 +23,50 @@ export interface DispositivosDisponiveis {
}
/**
* Obter configuração do Jitsi baseada em variáveis de ambiente
* Obter configuração do Jitsi do backend ou variáveis de ambiente (fallback)
*
* @param configBackend - Configuração do backend (opcional). Se fornecida, será usada.
* @returns Configuração do Jitsi
*/
export function obterConfiguracaoJitsi(): ConfiguracaoJitsi {
export function obterConfiguracaoJitsi(configBackend?: {
domain: string;
appId: string;
roomPrefix: string;
useHttps: boolean;
acceptSelfSignedCert?: boolean;
} | null): ConfiguracaoJitsi {
// Se há configuração do backend e está ativa, usar ela
if (configBackend) {
return {
domain: configBackend.domain,
appId: configBackend.appId,
roomPrefix: configBackend.roomPrefix,
useHttps: configBackend.useHttps,
acceptSelfSignedCert: configBackend.acceptSelfSignedCert || false
};
}
// Fallback para variáveis de ambiente
const domain = import.meta.env.VITE_JITSI_DOMAIN || 'localhost:8443';
const appId = import.meta.env.VITE_JITSI_APP_ID || 'sgse-app';
const roomPrefix = import.meta.env.VITE_JITSI_ROOM_PREFIX || 'sgse';
const useHttps = import.meta.env.VITE_JITSI_USE_HTTPS === 'true' || domain.includes(':8443');
const acceptSelfSignedCert = import.meta.env.VITE_JITSI_ACCEPT_SELF_SIGNED === 'true';
return {
domain,
appId,
roomPrefix,
useHttps,
acceptSelfSignedCert
};
}
/**
* Obter configuração do Jitsi de forma síncrona (apenas variáveis de ambiente)
* Use esta função quando não houver acesso ao Convex client
*/
export function obterConfiguracaoJitsiSync(): ConfiguracaoJitsi {
const domain = import.meta.env.VITE_JITSI_DOMAIN || 'localhost:8443';
const appId = import.meta.env.VITE_JITSI_APP_ID || 'sgse-app';
const roomPrefix = import.meta.env.VITE_JITSI_ROOM_PREFIX || 'sgse';
@@ -49,9 +91,19 @@ export function obterHostEPorta(domain: string): { host: string; porta: number }
/**
* Gerar nome único para a sala Jitsi
*
* @param conversaId - ID da conversa
* @param tipo - Tipo de chamada ('audio' ou 'video')
* @param configBackend - Configuração do backend (opcional). Se não fornecida, usa fallback.
*/
export function gerarRoomName(conversaId: string, tipo: 'audio' | 'video'): string {
const config = obterConfiguracaoJitsi();
export function gerarRoomName(
conversaId: string,
tipo: 'audio' | 'video',
configBackend?: {
roomPrefix: string;
} | null
): string {
const config = obterConfiguracaoJitsi(configBackend || undefined);
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 9);
const conversaHash = conversaId.replace(/[^a-zA-Z0-9]/g, '').substring(0, 10);
@@ -61,9 +113,18 @@ export function gerarRoomName(conversaId: string, tipo: 'audio' | 'video'): stri
/**
* Obter URL completa da sala Jitsi
*
* @param roomName - Nome da sala Jitsi
* @param configBackend - Configuração do backend (opcional). Se não fornecida, usa fallback.
*/
export function obterUrlSala(roomName: string): string {
const config = obterConfiguracaoJitsi();
export function obterUrlSala(
roomName: string,
configBackend?: {
domain: string;
useHttps: boolean;
} | null
): string {
const config = obterConfiguracaoJitsi(configBackend || undefined);
const protocol = config.useHttps ? 'https' : 'http';
return `${protocol}://${config.domain}/${roomName}`;
}

View File

@@ -0,0 +1,82 @@
/**
* Polyfill global para BlobBuilder
* Deve ser executado ANTES de qualquer import de lib-jitsi-meet
*
* BlobBuilder é uma API antiga dos navegadores que foi substituída pelo construtor Blob
* A biblioteca lib-jitsi-meet pode tentar usar BlobBuilder em navegadores modernos
*/
export function adicionarBlobBuilderPolyfill(): void {
if (typeof window === 'undefined') return;
// Verificar se já foi adicionado (evitar múltiplas execuções)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((window as any).__blobBuilderPolyfillAdded) {
return;
}
// Implementar BlobBuilder usando Blob moderno
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const BlobBuilderClass = class BlobBuilder {
private parts: BlobPart[] = [];
append(data: BlobPart): void {
this.parts.push(data);
}
getBlob(contentType?: string): Blob {
return new Blob(this.parts, contentType ? { type: contentType } : undefined);
}
};
// Adicionar em todos os possíveis locais onde a biblioteca pode procurar
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = window as any;
// Definir BlobBuilder se não existir
if (typeof win.BlobBuilder === 'undefined') {
win.BlobBuilder = BlobBuilderClass;
}
// Variantes de navegadores antigos
if (typeof win.WebKitBlobBuilder === 'undefined') {
win.WebKitBlobBuilder = BlobBuilderClass;
}
if (typeof win.MozBlobBuilder === 'undefined') {
win.MozBlobBuilder = BlobBuilderClass;
}
if (typeof win.MSBlobBuilder === 'undefined') {
win.MSBlobBuilder = BlobBuilderClass;
}
// Adicionar no global scope
if (typeof globalThis !== 'undefined') {
if (typeof (globalThis as any).BlobBuilder === 'undefined') {
(globalThis as any).BlobBuilder = BlobBuilderClass;
}
if (typeof (globalThis as any).WebKitBlobBuilder === 'undefined') {
(globalThis as any).WebKitBlobBuilder = BlobBuilderClass;
}
if (typeof (globalThis as any).MozBlobBuilder === 'undefined') {
(globalThis as any).MozBlobBuilder = BlobBuilderClass;
}
}
// Marcar que o polyfill foi adicionado
win.__blobBuilderPolyfillAdded = true;
console.log('✅ Polyfill BlobBuilder adicionado globalmente');
}
// Executar imediatamente se estiver no browser
if (typeof window !== 'undefined') {
adicionarBlobBuilderPolyfill();
}

View File

@@ -28,13 +28,14 @@
// Parâmetros reativos para queries
const registrosParams = $derived({
funcionarioId: funcionarioIdFiltro || undefined,
funcionarioId: funcionarioIdFiltro && funcionarioIdFiltro !== '' ? funcionarioIdFiltro : undefined,
dataInicio,
dataFim,
});
const estatisticasParams = $derived({
dataInicio,
dataFim,
funcionarioId: funcionarioIdFiltro && funcionarioIdFiltro !== '' ? funcionarioIdFiltro : undefined,
});
// Queries
@@ -176,6 +177,12 @@
// Inicializar gráfico quando canvas e dados estiverem disponíveis
$effect(() => {
if (chartCanvas && estatisticas && chartData) {
// Destruir gráfico anterior se existir
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
// Aguardar um pouco para garantir que o canvas está renderizado
const timeoutId = setTimeout(() => {
criarGrafico();
@@ -1748,7 +1755,6 @@
{/if}
<!-- Gráfico de Estatísticas -->
{#if estatisticas}
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 shadow-xl mb-8">
<div class="card-body">
<div class="flex items-center justify-between mb-6">
@@ -1760,16 +1766,41 @@
</h2>
</div>
<div class="h-80 w-full relative rounded-xl bg-base-200/50 p-4 border border-base-300">
{#if estatisticasQuery === undefined || estatisticasQuery?.isLoading}
<div class="absolute inset-0 flex items-center justify-center bg-base-200/30 rounded-xl">
<div class="flex flex-col items-center gap-4">
<span class="loading loading-spinner loading-lg text-primary"></span>
<span class="text-base-content/70 font-medium">Carregando estatísticas...</span>
</div>
</div>
{:else if estatisticasQuery?.error}
<div class="absolute inset-0 flex items-center justify-center bg-base-200/30 rounded-xl">
<div class="alert alert-error shadow-lg">
<XCircle class="h-6 w-6" />
<div>
<h3 class="font-bold">Erro ao carregar estatísticas</h3>
<div class="text-sm mt-1">{estatisticasQuery.error?.message || String(estatisticasQuery.error) || 'Erro desconhecido'}</div>
</div>
</div>
</div>
{:else if !estatisticas || !chartData}
<div class="absolute inset-0 flex items-center justify-center bg-base-200/30 rounded-xl">
<div class="text-center">
<FileText class="h-12 w-12 text-base-content/30 mx-auto mb-2" />
<p class="text-base-content/70">Nenhuma estatística disponível</p>
</div>
</div>
{:else}
<canvas bind:this={chartCanvas} class="w-full h-full"></canvas>
{#if !chartInstance && estatisticas}
<div class="absolute inset-0 flex items-center justify-center">
{#if !chartInstance && estatisticas && chartData}
<div class="absolute inset-0 flex items-center justify-center bg-base-200/30 rounded-xl">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{/if}
{/if}
</div>
</div>
</div>
{/if}
<!-- Filtros -->
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 shadow-xl mb-8">
@@ -1860,7 +1891,7 @@
{/if}
</div>
{#if registrosQuery?.status === 'Loading'}
{#if registrosQuery === undefined || registrosQuery?.isLoading}
<div class="flex flex-col items-center justify-center py-16 bg-base-200/50 rounded-xl border border-base-300">
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
<span class="text-base-content/70 font-medium">Carregando registros...</span>
@@ -1871,7 +1902,7 @@
<XCircle class="h-6 w-6" />
<div>
<h3 class="font-bold">Erro ao carregar registros</h3>
<div class="text-sm mt-1">{registrosQuery.error.message || 'Erro desconhecido'}</div>
<div class="text-sm mt-1">{registrosQuery.error?.message || String(registrosQuery.error) || 'Erro desconhecido'}</div>
</div>
</div>
{:else if !registrosQuery?.data}

View File

@@ -12,7 +12,8 @@
| 'document'
| 'teams'
| 'userPlus'
| 'clock';
| 'clock'
| 'video';
type PaletteKey = 'primary' | 'success' | 'secondary' | 'accent' | 'info' | 'error' | 'warning';
type TiRouteId =
@@ -28,7 +29,8 @@
| '/(dashboard)/ti/notificacoes'
| '/(dashboard)/ti/monitoramento'
| '/(dashboard)/ti/configuracoes-ponto'
| '/(dashboard)/ti/configuracoes-relogio';
| '/(dashboard)/ti/configuracoes-relogio'
| '/(dashboard)/ti/configuracoes-jitsi';
type FeatureCard = {
title: string;
@@ -202,6 +204,13 @@
strokeLinecap: 'round',
strokeLinejoin: 'round'
}
],
video: [
{
d: 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z',
strokeLinecap: 'round',
strokeLinejoin: 'round'
}
]
};
@@ -259,6 +268,15 @@
palette: 'secondary',
icon: 'envelope'
},
{
title: 'Configurações do Jitsi',
description:
'Configure o servidor Jitsi Meet para chamadas de vídeo e áudio no chat. Ajuste domínio, App ID e prefixo de salas.',
ctaLabel: 'Configurar Jitsi',
href: '/(dashboard)/ti/configuracoes-jitsi',
palette: 'primary',
icon: 'video'
},
{
title: 'Configurações de Ponto',
description:

View File

@@ -0,0 +1,876 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
const client = useConvexClient();
const currentUser = useQuery(api.auth.getCurrentUser, {});
const configAtual = useQuery(api.configuracaoJitsi.obterConfigJitsi, {});
// Query condicional para configuração completa
const configCompletaQuery = $derived(
configAtual?.data?._id ? { configId: configAtual.data._id } : null
);
const configCompleta = useQuery(
api.configuracaoJitsi.obterConfigJitsiCompleta,
configCompletaQuery ? configCompletaQuery : 'skip'
);
let domain = $state('');
let appId = $state('sgse-app');
let roomPrefix = $state('sgse');
let useHttps = $state(false);
let acceptSelfSignedCert = $state(false);
// Campos SSH/Docker
let sshHost = $state('');
let sshPort = $state(22);
let sshUsername = $state('');
let sshPassword = $state('');
let sshKeyPath = $state('');
let dockerComposePath = $state('');
let jitsiConfigPath = $state('~/.jitsi-meet-cfg');
let mostrarConfigSSH = $state(false);
let processando = $state(false);
let testando = $state(false);
let testandoSSH = $state(false);
let aplicandoServidor = $state(false);
let mensagem = $state<{ tipo: 'success' | 'error'; texto: string; detalhes?: string } | null>(
null
);
function mostrarMensagem(tipo: 'success' | 'error', texto: string, detalhes?: string) {
mensagem = { tipo, texto, detalhes };
setTimeout(() => {
mensagem = null;
}, 8000);
}
// Carregar config existente
$effect(() => {
if (configAtual?.data) {
domain = configAtual.data.domain || '';
appId = configAtual.data.appId || 'sgse-app';
roomPrefix = configAtual.data.roomPrefix || 'sgse';
useHttps = configAtual.data.useHttps ?? false;
acceptSelfSignedCert = configAtual.data.acceptSelfSignedCert ?? false;
} else if (configAtual === null) {
// Se não há configuração, resetar para valores padrão
domain = '';
appId = 'sgse-app';
roomPrefix = 'sgse';
useHttps = false;
acceptSelfSignedCert = false;
}
});
// Carregar configurações SSH/Docker
$effect(() => {
if (configCompleta?.data) {
sshHost = configCompleta.data.sshHost || '';
sshPort = configCompleta.data.sshPort || 22;
sshUsername = configCompleta.data.sshUsername || '';
sshPassword = ''; // Sempre limpar senha por segurança
sshKeyPath = configCompleta.data.sshKeyPath || '';
dockerComposePath = configCompleta.data.dockerComposePath || '';
jitsiConfigPath = configCompleta.data.jitsiConfigPath || '~/.jitsi-meet-cfg';
mostrarConfigSSH = !!(configCompleta.data.sshHost || configCompleta.data.sshUsername);
}
});
// Ativar HTTPS automaticamente se domínio contém porta 8443
$effect(() => {
if (domain.includes(':8443')) {
useHttps = true;
// Para localhost com porta 8443, geralmente é certificado autoassinado
if (domain.includes('localhost')) {
acceptSelfSignedCert = true;
}
}
});
async function salvarConfiguracao() {
// Validação de campos obrigatórios
if (!domain?.trim() || !appId?.trim() || !roomPrefix?.trim()) {
mostrarMensagem('error', 'Preencha todos os campos obrigatórios');
return;
}
// Validação de roomPrefix (apenas letras, números e hífens)
const roomPrefixRegex = /^[a-zA-Z0-9-]+$/;
if (!roomPrefixRegex.test(roomPrefix.trim())) {
mostrarMensagem('error', 'Prefixo de sala deve conter apenas letras, números e hífens');
return;
}
if (!currentUser?.data) {
mostrarMensagem('error', 'Usuário não autenticado');
return;
}
processando = true;
try {
const resultado = await client.mutation(api.configuracaoJitsi.salvarConfigJitsi, {
domain: domain.trim(),
appId: appId.trim(),
roomPrefix: roomPrefix.trim(),
useHttps,
acceptSelfSignedCert,
configuradoPorId: currentUser.data._id as Id<'usuarios'>,
// Configurações SSH/Docker (opcionais)
sshHost: sshHost.trim() || undefined,
sshPort: sshPort || undefined,
sshUsername: sshUsername.trim() || undefined,
sshPassword: sshPassword.trim() || undefined,
sshKeyPath: sshKeyPath.trim() || undefined,
dockerComposePath: dockerComposePath.trim() || undefined,
jitsiConfigPath: jitsiConfigPath.trim() || undefined
});
if (resultado.sucesso) {
mostrarMensagem('success', 'Configuração salva com sucesso!');
} else {
mostrarMensagem('error', resultado.erro);
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Erro ao salvar configuração:', error);
mostrarMensagem('error', errorMessage || 'Erro ao salvar configuração');
} finally {
processando = false;
}
}
async function testarConexao() {
if (!domain?.trim()) {
mostrarMensagem('error', 'Preencha o domínio antes de testar');
return;
}
testando = true;
try {
const resultado = await client.action(api.configuracaoJitsi.testarConexaoJitsi, {
domain: domain.trim(),
useHttps,
acceptSelfSignedCert
});
if (resultado.sucesso) {
const mensagemSucesso = resultado.aviso
? `Conexão testada com sucesso! ${resultado.aviso}`
: 'Conexão testada com sucesso! Servidor Jitsi está acessível.';
mostrarMensagem('success', mensagemSucesso);
} else {
mostrarMensagem('error', `Erro ao testar conexão: ${resultado.erro}`);
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Erro ao testar conexão:', error);
mostrarMensagem('error', errorMessage || 'Erro ao conectar com o servidor Jitsi');
} finally {
testando = false;
}
}
async function testarConexaoSSH() {
if (!sshHost?.trim() || !sshUsername?.trim()) {
mostrarMensagem('error', 'Preencha Host e Usuário SSH antes de testar');
return;
}
if (!sshPassword?.trim() && !sshKeyPath?.trim()) {
mostrarMensagem('error', 'Preencha a senha SSH ou o caminho da chave antes de testar');
return;
}
testandoSSH = true;
try {
const resultado = await client.action(api.actions.jitsiServer.testarConexaoSSH, {
sshHost: sshHost.trim(),
sshPort: sshPort || 22,
sshUsername: sshUsername.trim(),
sshPassword: sshPassword.trim() || undefined,
sshKeyPath: sshKeyPath.trim() || undefined
});
if (resultado.sucesso) {
mostrarMensagem('success', resultado.mensagem);
} else {
mostrarMensagem('error', `Erro ao testar SSH: ${resultado.erro}`);
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Erro ao testar SSH:', error);
mostrarMensagem('error', errorMessage || 'Erro ao conectar via SSH');
} finally {
testandoSSH = false;
}
}
async function aplicarConfiguracaoServidor() {
if (!configAtual?.data?._id) {
mostrarMensagem('error', 'Salve a configuração básica antes de aplicar no servidor');
return;
}
if (!sshHost?.trim() || !sshUsername?.trim()) {
mostrarMensagem('error', 'Configure o acesso SSH antes de aplicar no servidor');
return;
}
// Senha SSH é necessária para aplicar (pode ser a armazenada ou uma nova)
if (!sshPassword?.trim() && !sshKeyPath?.trim() && !configCompleta?.data?.sshPasswordHash) {
mostrarMensagem(
'error',
'Forneça a senha SSH ou o caminho da chave para aplicar a configuração'
);
return;
}
if (
!confirm(
'Deseja aplicar essas configurações no servidor Jitsi Docker? Os containers serão reiniciados.'
)
) {
return;
}
aplicandoServidor = true;
try {
const resultado = await client.action(api.actions.jitsiServer.aplicarConfiguracaoServidor, {
configId: configAtual.data._id,
sshPassword: sshPassword.trim() || undefined
});
if (resultado.sucesso) {
mostrarMensagem('success', resultado.mensagem, resultado.detalhes);
// Limpar senha após uso
sshPassword = '';
} else {
mostrarMensagem('error', `Erro ao aplicar configuração: ${resultado.erro}`);
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Erro ao aplicar configuração:', error);
mostrarMensagem('error', errorMessage || 'Erro ao aplicar configuração no servidor');
} finally {
aplicandoServidor = false;
}
}
const statusConfig = $derived(configAtual?.data?.ativo ? 'Configurado' : 'Não configurado');
const configuradoNoServidor = $derived(configCompleta?.data?.configuradoNoServidor ?? false);
const isLoading = $derived(configAtual === undefined);
const hasError = $derived(configAtual === null && !isLoading);
</script>
<div class="container mx-auto max-w-4xl px-4 py-6">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="bg-primary/10 rounded-xl p-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</div>
<div>
<h1 class="text-base-content text-3xl font-bold">Configurações do Jitsi Meet</h1>
<p class="text-base-content/60 mt-1">
Configurar servidor Jitsi para chamadas de vídeo e áudio
</p>
</div>
</div>
</div>
<!-- Mensagens -->
{#if mensagem}
<div
class="alert mb-6"
class:alert-success={mensagem.tipo === 'success'}
class:alert-error={mensagem.tipo === 'error'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
{#if mensagem.tipo === 'success'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
{/if}
</svg>
<div class="flex-1">
<span>{mensagem.texto}</span>
{#if mensagem.detalhes}
<details class="mt-2">
<summary class="cursor-pointer text-sm opacity-75">Detalhes</summary>
<pre
class="bg-base-200 mt-2 rounded p-2 font-mono text-xs whitespace-pre-wrap">{mensagem.detalhes}</pre>
</details>
{/if}
</div>
</div>
{/if}
<!-- Loading State -->
{#if isLoading}
<div class="alert alert-info mb-6">
<span class="loading loading-spinner loading-sm"></span>
<span>Carregando configurações...</span>
</div>
{/if}
<!-- Status -->
{#if !isLoading}
<div class="alert {configAtual?.data?.ativo ? 'alert-success' : 'alert-warning'} mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
{#if configAtual?.data?.ativo}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
{/if}
</svg>
<span>
<strong>Status:</strong>
{statusConfig}
{#if configAtual?.data?.testadoEm}
- Última conexão testada em {new Date(configAtual.data.testadoEm).toLocaleString('pt-BR')}
{/if}
</span>
</div>
{/if}
<!-- Formulário -->
{#if !isLoading}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Dados do Servidor Jitsi</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Domínio -->
<div class="form-control md:col-span-2">
<label class="label" for="jitsi-domain">
<span class="label-text font-medium">Domínio do Servidor *</span>
</label>
<input
id="jitsi-domain"
type="text"
bind:value={domain}
placeholder="localhost:8443 ou meet.example.com"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt"
>Ex: localhost:8443 (local), meet.example.com (produção)</span
>
</div>
</div>
<!-- App ID -->
<div class="form-control">
<label class="label" for="jitsi-app-id">
<span class="label-text font-medium">App ID *</span>
</label>
<input
id="jitsi-app-id"
type="text"
bind:value={appId}
placeholder="sgse-app"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">Identificador da aplicação Jitsi</span>
</div>
</div>
<!-- Room Prefix -->
<div class="form-control">
<label class="label" for="jitsi-room-prefix">
<span class="label-text font-medium">Prefixo de Sala *</span>
</label>
<input
id="jitsi-room-prefix"
type="text"
bind:value={roomPrefix}
placeholder="sgse"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">Apenas letras, números e hífens</span>
</div>
</div>
</div>
<!-- Opções de Segurança -->
<div class="divider"></div>
<h3 class="mb-2 font-bold">Configurações de Segurança</h3>
<div class="flex flex-col gap-4">
<div class="form-control">
<label class="label cursor-pointer gap-3">
<input type="checkbox" bind:checked={useHttps} class="checkbox checkbox-primary" />
<span class="label-text font-medium">Usar HTTPS</span>
</label>
<div class="label">
<span class="label-text-alt"
>Ativado automaticamente se domínio contém :8443. Desmarque para usar HTTP (não
recomendado para produção)</span
>
</div>
</div>
<div class="form-control">
<label class="label cursor-pointer gap-3">
<input
type="checkbox"
bind:checked={acceptSelfSignedCert}
class="checkbox checkbox-warning"
/>
<span class="label-text font-medium">Aceitar Certificados Autoassinados</span>
</label>
<div class="label">
<span class="label-text-alt text-warning"
>Habilitar apenas para desenvolvimento local com certificados autoassinados. Em
produção, use certificados válidos.</span
>
</div>
</div>
</div>
<!-- Configurações SSH/Docker -->
<div class="divider"></div>
<div class="mb-2 flex items-center justify-between">
<h3 class="font-bold">Configuração SSH/Docker (Opcional)</h3>
<label class="label cursor-pointer gap-2">
<span class="label-text text-sm">Configurar servidor via SSH</span>
<input type="checkbox" bind:checked={mostrarConfigSSH} class="checkbox checkbox-sm" />
</label>
</div>
{#if mostrarConfigSSH}
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- SSH Host -->
<div class="form-control">
<label class="label" for="ssh-host">
<span class="label-text font-medium">Host SSH *</span>
</label>
<input
id="ssh-host"
type="text"
bind:value={sshHost}
placeholder="192.168.1.100 ou servidor.local"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">Endereço do servidor Docker</span>
</div>
</div>
<!-- SSH Port -->
<div class="form-control">
<label class="label" for="ssh-port">
<span class="label-text font-medium">Porta SSH</span>
</label>
<input
id="ssh-port"
type="number"
bind:value={sshPort}
placeholder="22"
min="1"
max="65535"
class="input input-bordered"
/>
</div>
<!-- SSH Username -->
<div class="form-control">
<label class="label" for="ssh-username">
<span class="label-text font-medium">Usuário SSH *</span>
</label>
<input
id="ssh-username"
type="text"
bind:value={sshUsername}
placeholder="usuario"
class="input input-bordered"
/>
</div>
<!-- SSH Password ou Key Path -->
<div class="form-control">
<label class="label" for="ssh-password">
<span class="label-text font-medium">Senha SSH</span>
</label>
<input
id="ssh-password"
type="password"
bind:value={sshPassword}
placeholder="Deixe vazio para manter senha salva"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">Ou use caminho da chave SSH abaixo</span>
</div>
</div>
<!-- SSH Key Path -->
<div class="form-control md:col-span-2">
<label class="label" for="ssh-key-path">
<span class="label-text font-medium">Caminho da Chave SSH</span>
</label>
<input
id="ssh-key-path"
type="text"
bind:value={sshKeyPath}
placeholder="/home/usuario/.ssh/id_rsa"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">Caminho no servidor SSH para a chave privada</span>
</div>
</div>
<!-- Docker Compose Path -->
<div class="form-control">
<label class="label" for="docker-compose-path">
<span class="label-text font-medium">Caminho Docker Compose</span>
</label>
<input
id="docker-compose-path"
type="text"
bind:value={dockerComposePath}
placeholder="/home/usuario/jitsi-docker"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">Diretório com docker-compose.yml</span>
</div>
</div>
<!-- Jitsi Config Path -->
<div class="form-control">
<label class="label" for="jitsi-config-path">
<span class="label-text font-medium">Caminho Config Jitsi</span>
</label>
<input
id="jitsi-config-path"
type="text"
bind:value={jitsiConfigPath}
placeholder="~/.jitsi-meet-cfg"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">Diretório de configurações do Jitsi</span>
</div>
</div>
</div>
<!-- Status Configuração Servidor -->
{#if configuradoNoServidor}
<div class="alert alert-success mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
Configuração aplicada no servidor
{#if configCompleta?.data?.configuradoNoServidorEm}
em {new Date(configCompleta.data.configuradoNoServidorEm).toLocaleString('pt-BR')}
{/if}
</span>
</div>
{/if}
<!-- Botões SSH/Docker -->
<div class="mt-4 flex gap-3">
<button
class="btn btn-outline btn-info"
onclick={testarConexaoSSH}
disabled={testandoSSH || processando || aplicandoServidor}
>
{#if testandoSSH}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{/if}
Testar SSH
</button>
<button
class="btn btn-success"
onclick={aplicarConfiguracaoServidor}
disabled={aplicandoServidor ||
processando ||
testando ||
testandoSSH ||
!configAtual?.data?._id}
>
{#if aplicandoServidor}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
Aplicar no Servidor Docker
</button>
</div>
{/if}
<!-- Ações -->
<div class="card-actions mt-6 justify-end gap-3">
<button
class="btn btn-outline btn-info"
onclick={testarConexao}
disabled={testando || processando}
>
{#if testando}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{/if}
Testar Conexão
</button>
<button
class="btn btn-primary"
onclick={salvarConfiguracao}
disabled={processando || testando}
>
{#if processando}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
/>
</svg>
{/if}
Salvar Configuração
</button>
</div>
</div>
</div>
{/if}
<!-- Exemplos Comuns -->
<div class="card bg-base-100 mt-6 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Exemplos de Configuração</h2>
<div class="overflow-x-auto">
<table class="table-sm table">
<thead>
<tr>
<th>Ambiente</th>
<th>Domínio</th>
<th>App ID</th>
<th>Prefixo Sala</th>
<th>HTTPS</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Docker Local</strong></td>
<td>localhost:8443</td>
<td>sgse-app</td>
<td>sgse</td>
<td>Sim</td>
</tr>
<tr>
<td><strong>Produção</strong></td>
<td>meet.example.com</td>
<td>sgse-app</td>
<td>sgse</td>
<td>Sim</td>
</tr>
<tr>
<td><strong>Desenvolvimento</strong></td>
<td>localhost:8000</td>
<td>sgse-app</td>
<td>sgse-dev</td>
<td>Não</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Avisos -->
<div class="alert alert-info mt-6">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div>
<p>
<strong>Dica:</strong> Para servidor Jitsi Docker local, use
<code>localhost:8443</code> com HTTPS habilitado. Para servidor em produção, use o domínio completo
do seu servidor Jitsi.
</p>
<p class="mt-1 text-sm">
A configuração será aplicada imediatamente após salvar. Usuários precisarão recarregar a
página para usar a nova configuração.
</p>
</div>
</div>
<!-- Aviso sobre Certificados Autoassinados -->
{#if acceptSelfSignedCert}
<div class="alert alert-warning mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
></path>
</svg>
<div>
<p class="font-bold">Certificados Autoassinados Ativados</p>
<p class="mt-1 text-sm">
Para certificados autoassinados (desenvolvimento local), os usuários precisarão aceitar o
certificado no navegador na primeira conexão. Em produção, use certificados válidos (Let's
Encrypt, etc.).
</p>
</div>
</div>
{/if}
<!-- Aviso sobre HTTP -->
{#if !useHttps}
<div class="alert alert-error mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
></path>
</svg>
<div>
<p class="font-bold">HTTP Ativado (Não Seguro)</p>
<p class="mt-1 text-sm">
O uso de HTTP não é recomendado para produção. Use HTTPS com certificado válido para
garantir segurança nas chamadas.
</p>
</div>
</div>
{/if}
</div>

View File

@@ -10,10 +10,20 @@
let portaNTP = $state(123);
let usarServidorExterno = $state(false);
let fallbackParaPC = $state(true);
let gmtOffset = $state(0);
let gmtOffset = $state(-3); // Padrão GMT-3 para Brasília
let processando = $state(false);
let testando = $state(false);
let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null);
let statusSincronizacao = $state<{
ultimaSincronizacao: number | null;
offsetSegundos: number | null;
usandoServidorExterno: boolean;
} | null>(null);
// Estados para os relógios
let timestampOriginal = $state<number | null>(null);
let timestampUTC = $state<number | null>(null);
let intervaloRelogio: ReturnType<typeof setInterval> | null = null;
$effect(() => {
if (configQuery?.data) {
@@ -21,10 +31,159 @@
portaNTP = configQuery.data.portaNTP || 123;
usarServidorExterno = configQuery.data.usarServidorExterno || false;
fallbackParaPC = configQuery.data.fallbackParaPC !== undefined ? configQuery.data.fallbackParaPC : true;
gmtOffset = configQuery.data.gmtOffset ?? 0;
gmtOffset = configQuery.data.gmtOffset ?? -3; // Padrão GMT-3 para Brasília
// Atualizar status de sincronização
statusSincronizacao = {
ultimaSincronizacao: configQuery.data.ultimaSincronizacao ?? null,
offsetSegundos: configQuery.data.offsetSegundos ?? null,
usandoServidorExterno: configQuery.data.usarServidorExterno || false,
};
}
});
// Função para obter tempo sincronizado
async function obterTempoSincronizado() {
try {
if (usarServidorExterno) {
// Se usar servidor externo, sincronizar com NTP
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
if (resultado.sucesso) {
timestampUTC = resultado.timestamp; // Timestamp UTC da fonte
// Calcular o timestamp original (antes do ajuste GMT)
timestampOriginal = timestampUTC;
}
} else {
// Se não usar servidor externo, usar tempo local do PC (igual ao relógio de ponto)
timestampUTC = Date.now();
timestampOriginal = timestampUTC;
}
} catch (error) {
console.error('Erro ao obter tempo sincronizado:', error);
// Fallback: usar tempo local
timestampUTC = Date.now();
timestampOriginal = timestampUTC;
}
}
// Inicializar e atualizar relógios periodicamente
$effect(() => {
// Obter tempo inicial quando configuração mudar
if (configQuery?.data) {
obterTempoSincronizado();
}
// Atualizar a cada segundo
intervaloRelogio = setInterval(() => {
if (timestampUTC !== null) {
// Incrementar em 1 segundo
timestampUTC += 1000;
timestampOriginal = timestampUTC;
} else {
// Se ainda não temos timestamp, tentar obter novamente
obterTempoSincronizado();
}
}, 1000);
// Limpar intervalo quando componente for destruído
return () => {
if (intervaloRelogio) {
clearInterval(intervaloRelogio);
}
};
});
// Funções para formatar os relógios
function formatarRelogio(timestamp: number | null, ajusteGMT: number = 0): string {
if (!timestamp) return '--:--:--';
// Aplicar GMT offset ao timestamp
// Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática
// Quando GMT ≠ 0, aplicar offset configurado ao timestamp
let timestampAjustado: number;
if (ajusteGMT !== 0) {
// Aplicar offset configurado
timestampAjustado = timestamp + (ajusteGMT * 60 * 60 * 1000);
} else {
// Quando GMT = 0, manter timestamp UTC puro
// O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
timestampAjustado = timestamp;
}
const data = new Date(timestampAjustado);
return data.toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
}
function formatarDataRelogio(timestamp: number | null, ajusteGMT: number = 0): string {
if (!timestamp) return '--/--/----';
// Aplicar GMT offset ao timestamp
// Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleDateString() fazer a conversão automática
// Quando GMT ≠ 0, aplicar offset configurado ao timestamp
let timestampAjustado: number;
if (ajusteGMT !== 0) {
// Aplicar offset configurado
timestampAjustado = timestamp + (ajusteGMT * 60 * 60 * 1000);
} else {
// Quando GMT = 0, manter timestamp UTC puro
// O toLocaleDateString() converterá automaticamente para o timezone local do navegador
timestampAjustado = timestamp;
}
const data = new Date(timestampAjustado);
return data.toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
// Função para formatar timestamp ISO
function formatarTimestampISO(timestamp: number | null): string {
if (!timestamp) return '--';
return new Date(timestamp).toISOString();
}
// Função para obter nome do fuso horário baseado no offset
function obterNomeFusoHorario(offset: number): string {
const fusos: Record<number, string> = {
'-12': 'IDLW (Linha Internacional de Data Oeste)',
'-11': 'HST (Hawaii-Aleutian Standard Time)',
'-10': 'HST (Hawaii Standard Time)',
'-9': 'AKST (Alaska Standard Time)',
'-8': 'PST (Pacific Standard Time)',
'-7': 'MST (Mountain Standard Time)',
'-6': 'CST (Central Standard Time)',
'-5': 'EST (Eastern Standard Time)',
'-4': 'AST (Atlantic Standard Time)',
'-3': 'BRT (Brasília Time) / ART (Argentina Time)',
'-2': 'GST (South Georgia Standard Time)',
'-1': 'AZOT (Azores Standard Time)',
'0': 'UTC / GMT (Greenwich Mean Time)',
'1': 'CET (Central European Time)',
'2': 'EET (Eastern European Time)',
'3': 'MSK (Moscow Standard Time)',
'4': 'GST (Gulf Standard Time)',
'5': 'PKT (Pakistan Standard Time)',
'6': 'BST (Bangladesh Standard Time)',
'7': 'ICT (Indochina Time)',
'8': 'CST (China Standard Time)',
'9': 'JST (Japan Standard Time)',
'10': 'AEST (Australian Eastern Standard Time)',
'11': 'SBT (Solomon Islands Time)',
'12': 'NZST (New Zealand Standard Time)',
};
return fusos[offset.toString()] || `UTC${offset >= 0 ? '+' : ''}${offset}`;
}
// Função para calcular diferença em horas e minutos
function calcularDiferencaFuso(offset: number): string {
const horas = Math.abs(offset);
const minutos = 0; // Offset em horas completas
return `${horas}h ${minutos}m ${offset < 0 ? 'atrás' : 'à frente'} de UTC`;
}
function mostrarMensagem(tipo: 'success' | 'error', texto: string) {
mensagem = { tipo, texto };
setTimeout(() => {
@@ -55,6 +214,9 @@
});
mostrarMensagem('success', 'Configuração salva com sucesso!');
// Recarregar configuração para atualizar status
// A query será atualizada automaticamente pelo useQuery
} catch (error) {
console.error('Erro ao salvar configuração:', error);
mostrarMensagem('error', error instanceof Error ? error.message : 'Erro ao salvar configuração');
@@ -69,10 +231,37 @@
try {
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
if (resultado.sucesso) {
// Atualizar status de sincronização
statusSincronizacao = {
ultimaSincronizacao: Date.now(),
offsetSegundos: resultado.offsetSegundos,
usandoServidorExterno: resultado.usandoServidorExterno,
};
// Atualizar timestamps dos relógios
timestampUTC = resultado.timestamp;
timestampOriginal = resultado.timestamp;
// Calcular horário atual com GMT offset
// Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática
let timestampAjustado: number;
if (gmtOffset !== 0) {
timestampAjustado = timestampUTC + (gmtOffset * 60 * 60 * 1000);
} else {
// Quando GMT = 0, manter timestamp UTC puro
// O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
timestampAjustado = timestampUTC;
}
const horarioAtual = new Date(timestampAjustado).toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
mostrarMensagem(
'success',
resultado.usandoServidorExterno
? `Sincronização bem-sucedida! Offset: ${resultado.offsetSegundos}s`
? `Sincronização bem-sucedida! Offset: ${resultado.offsetSegundos}s | Horário atual: ${horarioAtual}`
: 'Usando relógio do PC (servidor externo não disponível)'
);
} else {
@@ -85,6 +274,24 @@
testando = false;
}
}
// Função para atualizar relógios manualmente
async function atualizarRelogios() {
await obterTempoSincronizado();
mostrarMensagem('success', 'Relógios atualizados!');
}
function formatarDataHora(timestamp: number | null): string {
if (!timestamp) return 'Nunca';
return new Date(timestamp).toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
</script>
<div class="container mx-auto px-4 py-6 max-w-4xl">
@@ -117,6 +324,134 @@
</div>
{/if}
<!-- Relógios em Tempo Real -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title">Relógios Sincronizados</h2>
<button
class="btn btn-sm btn-outline btn-info"
onclick={atualizarRelogios}
disabled={testando || processando}
>
<RefreshCw class="h-4 w-4" />
Atualizar Relógios
</button>
</div>
<p class="text-sm text-base-content/70 mb-6">
Visualização em tempo real dos horários sincronizados. O primeiro relógio mostra o horário original (UTC) da fonte de sincronismo escolhida, e o segundo mostra o horário ajustado conforme o GMT configurado.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Relógio Original (UTC) -->
<div class="bg-gradient-to-br from-blue-500/10 to-blue-600/10 rounded-xl p-6 border-2 border-blue-500/30">
<div class="text-center">
<div class="text-sm font-semibold text-blue-600 uppercase tracking-wide mb-2">
Horário Original (UTC)
</div>
<div class="text-5xl font-bold text-blue-700 mb-2 font-mono">
{formatarRelogio(timestampOriginal, 0)}
</div>
<div class="text-sm text-blue-600/80 mb-1">
{formatarDataRelogio(timestampOriginal, 0)}
</div>
<!-- Divider -->
<div class="divider my-3 opacity-30"></div>
<!-- Detalhes do Fuso Horário -->
<div class="text-left space-y-2 mt-4">
<div class="text-xs">
<span class="font-semibold text-blue-700">Fuso Horário:</span>
<span class="text-blue-600/90 ml-1">UTC / GMT (Greenwich Mean Time)</span>
</div>
<div class="text-xs">
<span class="font-semibold text-blue-700">Offset UTC:</span>
<span class="text-blue-600/90 ml-1">±00:00 (Coordenado Universal)</span>
</div>
<div class="text-xs">
<span class="font-semibold text-blue-700">Fonte:</span>
<span class="text-blue-600/90 ml-1">
{statusSincronizacao?.usandoServidorExterno ? `Servidor NTP (${servidorNTP})` : 'Relógio do PC'}
</span>
</div>
{#if statusSincronizacao?.offsetSegundos !== null && statusSincronizacao?.offsetSegundos !== undefined}
<div class="text-xs">
<span class="font-semibold text-blue-700">Offset Calculado:</span>
<span class="text-blue-600/90 ml-1">
{statusSincronizacao.offsetSegundos > 0 ? '+' : ''}{statusSincronizacao.offsetSegundos}s
</span>
</div>
{/if}
{#if timestampOriginal}
<div class="text-xs">
<span class="font-semibold text-blue-700">Timestamp UTC:</span>
<span class="text-blue-600/90 ml-1 font-mono text-[10px]">
{formatarTimestampISO(timestampOriginal)}
</span>
</div>
{/if}
<div class="text-xs">
<span class="font-semibold text-blue-700">Formato Recebido:</span>
<span class="text-blue-600/90 ml-1">ISO 8601 (UTC)</span>
</div>
</div>
</div>
</div>
<!-- Relógio com GMT Ajustado -->
<div class="bg-gradient-to-br from-primary/10 to-primary/20 rounded-xl p-6 border-2 border-primary/30">
<div class="text-center">
<div class="text-sm font-semibold text-primary uppercase tracking-wide mb-2">
Horário com GMT {gmtOffset >= 0 ? '+' : ''}{gmtOffset}
</div>
<div class="text-5xl font-bold text-primary mb-2 font-mono">
{formatarRelogio(timestampUTC, gmtOffset)}
</div>
<div class="text-sm text-primary/80 mb-1">
{formatarDataRelogio(timestampUTC, gmtOffset)}
</div>
<!-- Divider -->
<div class="divider my-3 opacity-30"></div>
<!-- Detalhes do Fuso Horário Aplicado -->
<div class="text-left space-y-2 mt-4">
<div class="text-xs">
<span class="font-semibold text-primary">Fuso Horário:</span>
<span class="text-primary/90 ml-1">{obterNomeFusoHorario(gmtOffset)}</span>
</div>
<div class="text-xs">
<span class="font-semibold text-primary">Offset Configurado:</span>
<span class="text-primary/90 ml-1">
GMT{gmtOffset >= 0 ? '+' : ''}{gmtOffset} ({calcularDiferencaFuso(gmtOffset)})
</span>
</div>
<div class="text-xs">
<span class="font-semibold text-primary">Ajuste Aplicado:</span>
<span class="text-primary/90 ml-1">
{gmtOffset >= 0 ? '+' : ''}{gmtOffset}:00 UTC
</span>
</div>
{#if timestampUTC}
<div class="text-xs">
<span class="font-semibold text-primary">Timestamp Local:</span>
<span class="text-primary/90 ml-1 font-mono text-[10px]">
{formatarTimestampISO(gmtOffset !== 0 ? timestampUTC + (gmtOffset * 60 * 60 * 1000) : timestampUTC)}
</span>
</div>
{/if}
<div class="text-xs">
<span class="font-semibold text-primary">Status:</span>
<span class="text-primary/90 ml-1">Ajuste aplicado em tempo real</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Formulário -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
@@ -226,6 +561,34 @@
</div>
</div>
<!-- Status de Sincronização -->
{#if statusSincronizacao}
<div class="divider"></div>
<h2 class="card-title mb-4">Status de Sincronização</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="stat bg-base-200 rounded-lg p-4">
<div class="stat-title text-xs">Última Sincronização</div>
<div class="stat-value text-lg">
{formatarDataHora(statusSincronizacao.ultimaSincronizacao)}
</div>
</div>
<div class="stat bg-base-200 rounded-lg p-4">
<div class="stat-title text-xs">Offset Calculado</div>
<div class="stat-value text-lg">
{statusSincronizacao.offsetSegundos !== null
? `${statusSincronizacao.offsetSegundos > 0 ? '+' : ''}${statusSincronizacao.offsetSegundos}s`
: 'N/A'}
</div>
</div>
<div class="stat bg-base-200 rounded-lg p-4">
<div class="stat-title text-xs">Fonte de Tempo</div>
<div class="stat-value text-lg">
{statusSincronizacao.usandoServidorExterno ? 'Servidor NTP' : 'Relógio do PC'}
</div>
</div>
</div>
{/if}
<!-- Ações -->
<div class="card-actions justify-end mt-6 gap-3">
{#if usarServidorExterno}
@@ -272,7 +635,15 @@
específica.
</p>
<p class="text-sm mt-1">
Servidores NTP recomendados: pool.ntp.org, time.google.com, time.windows.com
Servidores NTP recomendados: pool.ntp.org, time.google.com, time.windows.com, ntp.br
</p>
<p class="text-sm mt-2">
<strong>Como funciona:</strong> O servidor NTP configurado é mapeado para uma API HTTP que retorna UTC.
O GMT offset configurado é então aplicado no frontend para exibir o horário correto.
</p>
<p class="text-sm mt-1">
<strong>Importante:</strong> Todos os servidores NTP retornam tempo em UTC. O GMT offset é aplicado
apenas uma vez no frontend para ajustar ao fuso horário local (ex: GMT-3 para Brasília).
</p>
</div>
</div>

View File

@@ -3,6 +3,8 @@
import Sidebar from "$lib/components/Sidebar.svelte";
import { createSvelteAuthClient } from "@mmailaender/convex-better-auth-svelte/svelte";
import { authClient } from "$lib/auth";
// Importar polyfill ANTES de qualquer outro código que possa usar Jitsi
import "$lib/utils/jitsiPolyfill";
const { children } = $props();