feat: implement comprehensive chat system with user presence management, notification handling, and avatar integration; enhance UI components for improved user experience

This commit is contained in:
2025-10-28 11:57:54 -03:00
parent 81e6eb4a42
commit ee2c9c3ae0
47 changed files with 8274 additions and 195 deletions

View File

@@ -1,174 +1,524 @@
<script lang="ts">
import { authStore } from "$lib/stores/auth.svelte";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { requestNotificationPermission } from "$lib/utils/notifications";
import { getAvatarUrl as generateAvatarUrl } from "$lib/utils/avatarGenerator";
onMount(() => {
if (!authStore.autenticado) {
goto("/");
const client = useConvexClient();
const perfil = useQuery(api.usuarios.obterPerfil, {});
// Estados
let nome = $state("");
let email = $state("");
let matricula = $state("");
let avatarSelecionado = $state("");
let statusMensagemInput = $state("");
let statusPresencaSelect = $state("online");
let notificacoesAtivadas = $state(true);
let somNotificacao = $state(true);
let uploadingFoto = $state(false);
let salvando = $state(false);
let mensagemSucesso = $state("");
// Sincronizar com perfil
$effect(() => {
if (perfil) {
nome = perfil.nome || "";
email = perfil.email || "";
matricula = perfil.matricula || "";
avatarSelecionado = perfil.avatar || "";
statusMensagemInput = perfil.statusMensagem || "";
statusPresencaSelect = perfil.statusPresenca || "online";
notificacoesAtivadas = perfil.notificacoesAtivadas ?? true;
somNotificacao = perfil.somNotificacao ?? true;
}
});
function formatarData(timestamp?: number): 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",
});
// Lista de avatares profissionais usando DiceBear - TODOS FELIZES E SORRIDENTES
const avatares = [
// Avatares masculinos (16)
{ id: "avatar-m-1", seed: "John-Happy", label: "Homem 1" },
{ id: "avatar-m-2", seed: "Peter-Smile", label: "Homem 2" },
{ id: "avatar-m-3", seed: "Michael-Joy", label: "Homem 3" },
{ id: "avatar-m-4", seed: "David-Glad", label: "Homem 4" },
{ id: "avatar-m-5", seed: "James-Cheerful", label: "Homem 5" },
{ id: "avatar-m-6", seed: "Robert-Bright", label: "Homem 6" },
{ id: "avatar-m-7", seed: "William-Joyful", label: "Homem 7" },
{ id: "avatar-m-8", seed: "Joseph-Merry", label: "Homem 8" },
{ id: "avatar-m-9", seed: "Thomas-Happy", label: "Homem 9" },
{ id: "avatar-m-10", seed: "Charles-Smile", label: "Homem 10" },
{ id: "avatar-m-11", seed: "Daniel-Joy", label: "Homem 11" },
{ id: "avatar-m-12", seed: "Matthew-Glad", label: "Homem 12" },
{ id: "avatar-m-13", seed: "Anthony-Cheerful", label: "Homem 13" },
{ id: "avatar-m-14", seed: "Mark-Bright", label: "Homem 14" },
{ id: "avatar-m-15", seed: "Donald-Joyful", label: "Homem 15" },
{ id: "avatar-m-16", seed: "Steven-Merry", label: "Homem 16" },
// Avatares femininos (16)
{ id: "avatar-f-1", seed: "Maria-Happy", label: "Mulher 1" },
{ id: "avatar-f-2", seed: "Ana-Smile", label: "Mulher 2" },
{ id: "avatar-f-3", seed: "Patricia-Joy", label: "Mulher 3" },
{ id: "avatar-f-4", seed: "Jennifer-Glad", label: "Mulher 4" },
{ id: "avatar-f-5", seed: "Linda-Cheerful", label: "Mulher 5" },
{ id: "avatar-f-6", seed: "Barbara-Bright", label: "Mulher 6" },
{ id: "avatar-f-7", seed: "Elizabeth-Joyful", label: "Mulher 7" },
{ id: "avatar-f-8", seed: "Jessica-Merry", label: "Mulher 8" },
{ id: "avatar-f-9", seed: "Sarah-Happy", label: "Mulher 9" },
{ id: "avatar-f-10", seed: "Karen-Smile", label: "Mulher 10" },
{ id: "avatar-f-11", seed: "Nancy-Joy", label: "Mulher 11" },
{ id: "avatar-f-12", seed: "Betty-Glad", label: "Mulher 12" },
{ id: "avatar-f-13", seed: "Helen-Cheerful", label: "Mulher 13" },
{ id: "avatar-f-14", seed: "Sandra-Bright", label: "Mulher 14" },
{ id: "avatar-f-15", seed: "Ashley-Joyful", label: "Mulher 15" },
{ id: "avatar-f-16", seed: "Kimberly-Merry", label: "Mulher 16" },
];
function getAvatarUrl(avatarId: string): string {
// Usar gerador local ao invés da API externa
return generateAvatarUrl(avatarId);
}
function getRoleBadgeClass(nivel: number): string {
if (nivel === 0) return "badge-error";
if (nivel === 1) return "badge-warning";
if (nivel === 2) return "badge-info";
return "badge-success";
async function handleUploadFoto(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Validar tipo
if (!file.type.startsWith("image/")) {
alert("Por favor, selecione uma imagem");
return;
}
// Validar tamanho (max 2MB)
if (file.size > 2 * 1024 * 1024) {
alert("A imagem deve ter no máximo 2MB");
return;
}
try {
uploadingFoto = true;
// 1. Obter upload URL
const uploadUrl = await client.mutation(api.usuarios.uploadFotoPerfil, {});
// 2. Upload da foto
const result = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
if (!result.ok) {
throw new Error("Falha no upload");
}
const { storageId } = await result.json();
// 3. Atualizar perfil
await client.mutation(api.usuarios.atualizarPerfil, {
fotoPerfil: storageId,
avatar: "", // Limpar avatar quando usa foto
});
mensagemSucesso = "Foto de perfil atualizada com sucesso!";
setTimeout(() => (mensagemSucesso = ""), 3000);
} catch (error) {
console.error("Erro ao fazer upload:", error);
alert("Erro ao fazer upload da foto");
} finally {
uploadingFoto = false;
input.value = "";
}
}
async function handleSelecionarAvatar(avatarId: string) {
try {
avatarSelecionado = avatarId;
await client.mutation(api.usuarios.atualizarPerfil, {
avatar: avatarId,
fotoPerfil: undefined, // Limpar foto quando usa avatar
});
mensagemSucesso = "Avatar atualizado com sucesso!";
setTimeout(() => (mensagemSucesso = ""), 3000);
} catch (error) {
console.error("Erro ao atualizar avatar:", error);
alert("Erro ao atualizar avatar");
}
}
async function handleSalvarConfiguracoes() {
try {
salvando = true;
// Validar statusMensagem
if (statusMensagemInput.length > 100) {
alert("A mensagem de status deve ter no máximo 100 caracteres");
return;
}
await client.mutation(api.usuarios.atualizarPerfil, {
statusMensagem: statusMensagemInput.trim() || undefined,
statusPresenca: statusPresencaSelect as any,
notificacoesAtivadas,
somNotificacao,
});
mensagemSucesso = "Configurações salvas com sucesso!";
setTimeout(() => (mensagemSucesso = ""), 3000);
} catch (error) {
console.error("Erro ao salvar configurações:", error);
alert("Erro ao salvar configurações");
} finally {
salvando = false;
}
}
async function handleSolicitarNotificacoes() {
const permission = await requestNotificationPermission();
if (permission === "granted") {
await client.mutation(api.usuarios.atualizarPerfil, { notificacoesAtivadas: true });
notificacoesAtivadas = true;
} else if (permission === "denied") {
alert(
"Você negou as notificações. Para ativá-las, permita notificações nas configurações do navegador."
);
}
}
</script>
<main class="container mx-auto px-4 py-8 max-w-4xl">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center gap-3 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
<div class="max-w-5xl mx-auto">
<div class="mb-6">
<h1 class="text-3xl font-bold text-base-content">Meu Perfil</h1>
<p class="text-base-content/70">Gerencie suas informações e preferências</p>
</div>
{#if mensagemSucesso}
<div class="alert alert-success mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
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>
<h1 class="text-4xl font-bold text-primary">Meu Perfil</h1>
<span>{mensagemSucesso}</span>
</div>
<p class="text-base-content/70 text-lg">
Informações da sua conta no sistema
</p>
</div>
{/if}
<!-- Breadcrumbs -->
<div class="text-sm breadcrumbs mb-6">
<ul>
<li><a href="/">Dashboard</a></li>
<li>Perfil</li>
</ul>
</div>
{#if authStore.usuario}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Card Principal -->
<div class="md:col-span-2 card bg-base-100 shadow-xl border border-base-300">
{#if perfil}
<div class="grid gap-6">
<!-- Card 1: Foto de Perfil -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center gap-4 mb-6">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-24">
<span class="text-3xl">{authStore.usuario.nome.charAt(0)}</span>
</div>
<h2 class="card-title">Foto de Perfil</h2>
<div class="flex flex-col md:flex-row items-center gap-6">
<!-- Preview -->
<div class="flex-shrink-0">
{#if perfil.fotoPerfilUrl}
<div class="avatar">
<div class="w-40 h-40 rounded-lg">
<img src={perfil.fotoPerfilUrl} alt="Foto de perfil" class="object-cover" />
</div>
</div>
{:else if perfil.avatar || avatarSelecionado}
<div class="avatar">
<div class="w-40 h-40 rounded-lg bg-base-200 overflow-hidden">
<img
src={getAvatarUrl(perfil.avatar || avatarSelecionado)}
alt="Avatar"
class="w-full h-full object-cover"
/>
</div>
</div>
{:else}
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-lg w-40 h-40">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-20 h-20"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
/>
</svg>
</div>
</div>
{/if}
</div>
<!-- Upload -->
<div class="flex-1">
<label class="btn btn-primary btn-block gap-2">
<input
type="file"
class="hidden"
accept="image/*"
onchange={handleUploadFoto}
disabled={uploadingFoto}
/>
{#if uploadingFoto}
<span class="loading loading-spinner"></span>
Fazendo upload...
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"
/>
</svg>
Carregar Foto
{/if}
</label>
<p class="text-xs text-base-content/60 mt-2">
Máximo 2MB. Formatos: JPG, PNG, GIF, WEBP
</p>
</div>
</div>
<!-- Grid de Avatares -->
<div class="divider">OU escolha um avatar profissional</div>
<div class="alert alert-info mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.182 15.182a4.5 4.5 0 0 1-6.364 0M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z"
/>
</svg>
<div>
<h2 class="text-2xl font-bold">{authStore.usuario.nome}</h2>
<p class="text-base-content/60">{authStore.usuario.email}</p>
<div class="mt-2">
<span class="badge {getRoleBadgeClass(authStore.usuario.role.nivel)} badge-lg">
{authStore.usuario.role.nome}
</span>
</div>
<p class="font-semibold">32 avatares disponíveis - Todos felizes e sorridentes! 😊</p>
</div>
</div>
<div class="grid grid-cols-4 md:grid-cols-8 lg:grid-cols-8 gap-3 max-h-96 overflow-y-auto p-2">
{#each avatares as avatar}
<button
type="button"
class={`relative w-full aspect-[3/4] rounded-lg overflow-hidden border-4 transition-all hover:scale-105 ${
avatarSelecionado === avatar.id
? "border-primary shadow-lg"
: "border-base-300 hover:border-primary/50"
}`}
onclick={() => handleSelecionarAvatar(avatar.id)}
title={avatar.label}
>
<img
src={getAvatarUrl(avatar.id)}
alt={avatar.label}
class="w-full h-full object-cover"
/>
{#if avatarSelecionado === avatar.id}
<div class="absolute inset-0 bg-primary/20 flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-10 h-10 text-primary"
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z"
clip-rule="evenodd"
/>
</svg>
</div>
{/if}
</button>
{/each}
</div>
</div>
</div>
<!-- Card 2: Informações Básicas -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Informações Básicas</h2>
<p class="text-sm text-base-content/70 mb-4">
Informações do seu cadastro (somente leitura)
</p>
<div class="grid md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Nome</span>
</label>
<input
type="text"
class="input input-bordered bg-base-200"
value={nome}
readonly
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">E-mail</span>
</label>
<input
type="email"
class="input input-bordered bg-base-200"
value={email}
readonly
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Matrícula</span>
</label>
<input
type="text"
class="input input-bordered bg-base-200"
value={matricula}
readonly
/>
</div>
</div>
<div class="divider"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-sm text-base-content/60 mb-1">Matrícula</p>
<p class="font-semibold text-lg">
<code class="bg-base-200 px-3 py-1 rounded">{authStore.usuario.matricula}</code>
</p>
</div>
<div>
<p class="text-sm text-base-content/60 mb-1">Nível de Acesso</p>
<p class="font-semibold text-lg">Nível {authStore.usuario.role.nivel}</p>
</div>
<div>
<p class="text-sm text-base-content/60 mb-1">E-mail</p>
<p class="font-semibold">{authStore.usuario.email}</p>
</div>
{#if authStore.usuario.role.setor}
<div>
<p class="text-sm text-base-content/60 mb-1">Setor</p>
<p class="font-semibold">{authStore.usuario.role.setor}</p>
</div>
{/if}
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Mensagem de Status do Chat</span>
<span class="label-text-alt">{statusMensagemInput.length}/100</span>
</label>
<textarea
class="textarea textarea-bordered h-20"
placeholder="Ex: Disponível para reuniões | Em atendimento | Ausente temporariamente"
bind:value={statusMensagemInput}
maxlength="100"
></textarea>
<label class="label">
<span class="label-text-alt">Este texto aparecerá abaixo do seu nome no chat</span>
</label>
</div>
</div>
</div>
<!-- Card Ações Rápidas -->
<div class="space-y-6">
<div class="card bg-base-100 shadow-xl border border-base-300">
<div class="card-body">
<h3 class="card-title text-lg mb-4">Ações Rápidas</h3>
<div class="space-y-2">
<a href="/alterar-senha" class="btn btn-primary btn-block justify-start">
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Alterar Senha
</a>
<a href="/" class="btn btn-ghost btn-block justify-start">
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Voltar ao Dashboard
</a>
</div>
<!-- Card 3: Preferências de Chat -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Preferências de Chat</h2>
<div class="form-control">
<label class="label">
<span class="label-text">Status de Presença</span>
</label>
<select class="select select-bordered" bind:value={statusPresencaSelect}>
<option value="online">🟢 Online</option>
<option value="ausente">🟡 Ausente</option>
<option value="externo">🔵 Externo</option>
<option value="em_reuniao">🔴 Em Reunião</option>
<option value="offline">⚫ Offline</option>
</select>
</div>
</div>
<div class="card bg-info/10 shadow-xl border border-info/30">
<div class="card-body">
<h3 class="card-title text-sm">
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<div class="divider"></div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={notificacoesAtivadas}
/>
<div>
<span class="label-text font-medium">Notificações Ativadas</span>
<p class="text-xs text-base-content/60">
Receber notificações de novas mensagens
</p>
</div>
</label>
</div>
{#if notificacoesAtivadas && typeof Notification !== "undefined" && Notification.permission !== "granted"}
<div class="alert alert-warning">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<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"
/>
</svg>
Informação
</h3>
<p class="text-sm text-base-content/70">
Para alterar outras informações do seu perfil, entre em contato com a equipe de TI.
</p>
<span>Você precisa permitir notificações no navegador</span>
<button type="button" class="btn btn-sm" onclick={handleSolicitarNotificacoes}>
Permitir
</button>
</div>
{/if}
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={somNotificacao}
/>
<div>
<span class="label-text font-medium">Som de Notificação</span>
<p class="text-xs text-base-content/60">
Tocar um som ao receber mensagens
</p>
</div>
</label>
</div>
<div class="card-actions justify-end mt-4">
<button
type="button"
class="btn btn-primary"
onclick={handleSalvarConfiguracoes}
disabled={salvando}
>
{#if salvando}
<span class="loading loading-spinner"></span>
Salvando...
{:else}
Salvar Configurações
{/if}
</button>
</div>
</div>
</div>
</div>
<!-- Card Segurança -->
<div class="card bg-base-100 shadow-xl border border-base-300 mt-6">
<div class="card-body">
<h3 class="card-title text-lg mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Segurança da Conta
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title">Status da Conta</div>
<div class="stat-value text-success text-2xl">Ativa</div>
<div class="stat-desc">Sua conta está ativa e segura</div>
</div>
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title">Primeiro Acesso</div>
<div class="stat-value text-2xl">{authStore.usuario.primeiroAcesso ? "Sim" : "Não"}</div>
<div class="stat-desc">
{#if authStore.usuario.primeiroAcesso}
Altere sua senha após o primeiro login
{:else}
Senha já foi alterada
{/if}
</div>
</div>
</div>
</div>
{:else}
<!-- Loading -->
<div class="flex items-center justify-center h-96">
<span class="loading loading-spinner loading-lg"></span>
</div>
{/if}
</main>
</div>