ajusteLogin

This commit is contained in:
2026-04-28 15:33:31 +01:00
parent 255c1a39f4
commit 46c92ead4e
2 changed files with 478 additions and 127 deletions

117
README.md
View File

@@ -1,78 +1,47 @@
# CondoMaster Pro
# CondoMasterResults
![CondoMaster Pro Preview](https://via.placeholder.com/800x400.png?text=CondoMaster+Pro+-+Gestão+de+Condomínios)
Uma plataforma moderna de gestão de condomínios focada em transparência, comunicação em tempo real e facilidade de uso, tanto para moradores como para a administração.
O **CondoMaster Pro** é uma aplicação web moderna e responsiva (Single Page Application - SPA) desenvolvida para simplificar e digitalizar a gestão de condomínios. Desenhado a pensar tanto na entidade gestora (Administradores) como nos habitantes (Moradores), o sistema integra todas as comunicações, finanças e ocorrências do dia a dia.
## 🎯 Sobre o Projeto
O **CondoMasterResults** é uma *Single Page Application* concebida para digitalizar a gestão do dia a dia num condomínio. Permite aos moradores consultar despesas, reservar espaços comuns e reportar ocorrências, enquanto oferece aos administradores um painel de controlo completo sobre as finanças e os utilizadores.
## 🚀 Funcionalidades Chave
* **Perfis Diferenciados**: Acessos específicos para Administradores (gestão total) e Moradores (painel simplificado).
* **Fórum em Tempo Real**: Chat integrado (Mural) sincronizado instantaneamente entre todos os vizinhos.
* **Gestão de Espaços Comuns**: Sistema prático de reservas para Ginásio, Salão de Festas e Parque de Jogos.
* **Portal de Manutenção**: Plataforma para reportar avarias (moradores) e gerir a resolução das mesmas (administração).
* **Gestão Financeira e Faturação**: Visão clara sobre quotas pendentes, geração de faturas e fluxo de caixa.
## 🛠️ Tecnologias Utilizadas
Esta aplicação corre integralmente no lado do cliente com forte integração de serviços cloud:
- **Frontend**: React (UI Declarativa), Tailwind CSS (Estilização) e Lucide React (Ícones).
- **Backend & Base de Dados**: Firebase Auth (Autenticação) e Firebase Realtime Database (Sincronização de dados em direto).
## 📥 Como Começar
Uma vez que a aplicação está desenhada para correr diretamente no navegador (recorrendo ao Firebase para armazenar estado):
1. Clona ou descarrega este repositório para o teu computador.
2. Abre a pasta do projeto num servidor local. Recomendamos o uso de ferramentas como o **Live Server** (extensão VS Code) ou a execução de `npx serve` no terminal.
3. Acede ao endereço gerado no teu navegador (ex: `http://localhost:3000`).
### 🔑 Credenciais de Teste
Podes explorar a plataforma utilizando as seguintes contas de demonstração:
**Administrador**
- Email: `administradores@gmail.com`
- Senha: `admin123`
**Morador**
- Email: `moradores@gmail.com`
- Senha: `moradores123`
*(Nota: É possível testar o envio de mensagens em tempo real abrindo duas janelas com contas diferentes lado a lado).*
---
## ✨ Funcionalidades Principais
### Acesso Baseado em Perfis (Role-Based Access Control)
* **🧑‍💻 Administradores (`admin`)**: Visão 360º. Podem gerir moradores, registar receitas e despesas globais, cobrar dívidas, enviar faturas manuais (com um clique) e alterar papéis de acesso ("promover" ou "despromover").
* **🏠 Moradores (`morador`)**: Painel simplificado desenhado para transparência. Permite verificar as próprias quotas em atraso, reportar danos/anomalias (manutenção) e reservar espaços comuns.
### 💰 Faturação e Gestão Financeira (Exclusivo Admins)
- Visão geral completa de Fluxo de Caixa (Despesas vs. Receitas).
- Emissão instantânea de recibos avulso.
- Notificações de dívidas encaminhadas com apenas um clique na tabela integrada de **Faturação**.
### 📅 Gestão de Reservas
* **Lista e Mapa**: Sistema visual de reservas em três ginásios, salões de festas e parques de jogos.
* **Histórico Completo**: Página exclusiva para listagem de todas as reservas agendadas, acessível a todas as entidades.
### 🛠️ Ocorrências e Manutenção
- Secção para os condóminos relatarem problemas no edifício (ex: candeeiros partidos, problemas de elevador) indicando o grau de severidade.
- Os administradores avaliam a prioridade, resolvem as ocorrências digitalmente e mantêm os residentes notificados do estado.
### 🎨 Design Moderno & UI Inteligente
* Compatível com **Mobile e Desktop**.
* Inclui um switch suave para **Modo Escuro (Dark Mode)**, Modo Claro e deteção por Sistema, integrados perfeitamente no menu de perfil.
* Sistema de notificações do tipo Themed/Toasts para validações imediatas (Confirmações, Erros, Avisos).
---
## 💻 Stack Tecnológica
O projeto foi construído usando uma arquitetura modular moderna num formato de ficheiro de entrada principal que integra os ecossistemas:
* **React**: Implementado diretamente do navegador (sem build step local). Geração de componentes declarativos (UI Dinâmica).
* **Tailwind CSS**: Carregado dinamicamente para aplicar estilos sofisticados e reativos, acelerando o desenvolvimento visual da interface.
* **Lucide React**: Biblioteca adotada inteiramente para a vasta panóplia de ícones (`lucide-react`).
---
## 🚀 Como Iniciar (Quick Start)
Visto que o projeto já traz toda a lógica baseada na Web injetada, não é precisa uma instalação exaustiva na máquina.
1. **Baixar o Projeto:**
Basta que tenhas o ficheiro principal (geralmente `index.html`) e o ambiente disponível na mesma pasta (neste caso `GestorCondominio`).
2. **Abrir a Aplicação:**
- Para pré-visualizar rapidamente a aplicação, podes apenas fazer duplo-clique no **`index.html`** para abrir o sistema num browser moderno.
- Alternativamente, podes hospedar este ficheiro num serviço de Live Server ou num host online (ex: Vercel, Netlify, Github Pages), não existindo configuração complexa.
---
## 🔐 Credenciais de Base (Ambiente de Testes)
Neste momento as credenciais estão pré-programadas para experimentação do comportamento do sistema:
**Acesso de Administrador:**
- **Email:** `administradores@gmail.com`
- **Palavra-passe:** `admin123`
**Acesso de Morador:**
- **Email:** `moradores@gmail.com`
- **Palavra-passe:** `moradores123`
*(Nota: Alguns moradores registados na base de dados fictícia no "Estado" da app podem aceder através da palavra-passe padrão de morador ou usando o respetivo contacto telefónico)*.
---
## 👨‍🔧 Desenvolvimento e Melhorias Mapeadas
* Ligação completa de base de dados escalável com a inicialização nativa contida do **Firebase**.
* Emissão e importação de documentos faturação automatizados PDF.
***Desenvolvido para criar comunidades perfeitamente ligadas.***
*Construído com simplicidade e foco na comunidade.*

View File

@@ -99,7 +99,7 @@
MessageCircle, Paperclip, Send
} from 'lucide-react';
import { app } from './firebase.js';
import { getAuth, signInWithEmailAndPassword } from 'https://www.gstatic.com/firebasejs/12.1.0/firebase-auth.js';
import { getAuth, signInWithEmailAndPassword, createUserWithEmailAndPassword } from 'https://www.gstatic.com/firebasejs/12.1.0/firebase-auth.js';
import { getDatabase, ref, push, set, onValue, remove } from 'https://www.gstatic.com/firebasejs/12.1.0/firebase-database.js';
const auth = getAuth(app);
@@ -257,7 +257,119 @@
);
};
const LoginView = ({ onLogin }) => {
const WaitingApprovalView = ({ onLogout }) => {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-100 dark:bg-dark-bg p-4 font-sans text-center">
<div className="bg-white dark:bg-dark-surface p-8 rounded-xl shadow-2xl w-full max-w-md border border-slate-100 dark:border-dark-border">
<div className="inline-flex p-4 bg-orange-100 dark:bg-orange-900/30 rounded-full mb-6 text-orange-600 dark:text-orange-400">
<AlertCircle size={48} />
</div>
<h2 className="text-2xl font-bold text-slate-800 dark:text-white mb-2">Conta Pendente</h2>
<p className="text-slate-500 dark:text-gray-400 mb-8">
O seu registo foi concluído com sucesso, mas a sua conta aguarda aprovação da administração. Por favor, aguarde.
</p>
<button onClick={onLogout} className="w-full bg-slate-200 hover:bg-slate-300 dark:bg-slate-700 dark:hover:bg-slate-600 text-slate-800 dark:text-white font-bold py-3 rounded-lg transition-colors">
Sair
</button>
</div>
</div>
);
};
const RegisterView = ({ onToggleView, onRegister }) => {
const [formData, setFormData] = useState({
name: '', dob: '', phone: '', cc: '', nif: '', unit: '', email: '', password: '', confirmPassword: ''
});
const [error, setError] = useState('');
const isFormValid = Object.values(formData).every(val => val.trim() !== '') && formData.password === formData.confirmPassword;
const handleSubmit = async (e) => {
e.preventDefault();
if (formData.password !== formData.confirmPassword) {
setError('As palavras-passe não coincidem.');
return;
}
const result = await onRegister(formData);
if (!result.success) {
setError(result.message || 'Ocorreu um erro ao tentar registar a conta.');
}
};
const handleChange = (e) => {
setFormData({...formData, [e.target.name]: e.target.value});
};
return (
<div className="min-h-screen flex items-center justify-center bg-slate-100 dark:bg-dark-bg p-4 py-12 transition-colors duration-300 font-sans">
<div className="bg-white dark:bg-dark-surface p-8 rounded-xl shadow-2xl w-full max-w-2xl animate-fade-in border border-slate-100 dark:border-dark-border">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-slate-800 dark:text-white">Criar Conta</h1>
<p className="text-slate-500 dark:text-gray-400 mt-2">Registo de Novo Morador</p>
</div>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="col-span-1 md:col-span-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Nome Completo</label>
<input type="text" name="name" value={formData.name} onChange={handleChange} required className="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-dark-border focus:ring-2 focus:ring-blue-500 bg-white dark:bg-dark-card text-slate-900 dark:text-white" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Data de Nascimento</label>
<input type="date" name="dob" value={formData.dob} onChange={handleChange} required className="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-dark-border focus:ring-2 focus:ring-blue-500 bg-white dark:bg-dark-card text-slate-900 dark:text-white" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"> Telemóvel</label>
<input type="tel" name="phone" value={formData.phone} onChange={handleChange} required className="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-dark-border focus:ring-2 focus:ring-blue-500 bg-white dark:bg-dark-card text-slate-900 dark:text-white" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Cartão de Cidadão</label>
<input type="text" name="cc" value={formData.cc} onChange={handleChange} required className="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-dark-border focus:ring-2 focus:ring-blue-500 bg-white dark:bg-dark-card text-slate-900 dark:text-white" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">NIF</label>
<input type="text" name="nif" value={formData.nif} onChange={handleChange} required className="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-dark-border focus:ring-2 focus:ring-blue-500 bg-white dark:bg-dark-card text-slate-900 dark:text-white" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Fração (Ex: 1º Dto)</label>
<input type="text" name="unit" value={formData.unit} onChange={handleChange} required className="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-dark-border focus:ring-2 focus:ring-blue-500 bg-white dark:bg-dark-card text-slate-900 dark:text-white" />
</div>
<div className="col-span-1 md:col-span-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Email</label>
<input type="email" name="email" value={formData.email} onChange={handleChange} required className="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-dark-border focus:ring-2 focus:ring-blue-500 bg-white dark:bg-dark-card text-slate-900 dark:text-white" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Palavra-passe</label>
<input type="password" name="password" value={formData.password} onChange={handleChange} required className="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-dark-border focus:ring-2 focus:ring-blue-500 bg-white dark:bg-dark-card text-slate-900 dark:text-white" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Confirmar Palavra-passe</label>
<input type="password" name="confirmPassword" value={formData.confirmPassword} onChange={handleChange} required className="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-dark-border focus:ring-2 focus:ring-blue-500 bg-white dark:bg-dark-card text-slate-900 dark:text-white" />
</div>
{error && (
<div className="col-span-1 md:col-span-2 p-3 bg-red-50 text-red-600 text-sm rounded-lg flex items-center gap-2">
<AlertCircle size={16} />
{error}
</div>
)}
<div className="col-span-1 md:col-span-2 mt-4 space-y-4">
<button type="submit" disabled={!isFormValid} className="w-full bg-blue-600 disabled:bg-blue-300 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition-colors">
Registar
</button>
<div className="text-center">
<button type="button" onClick={onToggleView} className="text-sm text-blue-600 hover:underline dark:text-blue-400">
tem conta? Iniciar Sessão
</button>
</div>
</div>
</form>
</div>
</div>
);
};
const LoginView = ({ onLogin, onToggleView }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
@@ -311,9 +423,16 @@
{error}
</div>
)}
<button type="submit" className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition-colors shadow-lg shadow-blue-500/30">
Entrar
</button>
<div className="space-y-4">
<button type="submit" className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition-colors shadow-lg shadow-blue-500/30">
Entrar
</button>
<div className="text-center">
<button type="button" onClick={onToggleView} className="text-sm text-blue-600 hover:underline dark:text-blue-400">
Ainda não tem conta? Registe-se
</button>
</div>
</div>
</form>
</div>
</div>
@@ -332,43 +451,171 @@
const [userRole, setUserRole] = useState(() => {
return sessionStorage.getItem('condo_role') || 'morador';
});
const [currentUserName, setCurrentUserName] = useState(() => {
return sessionStorage.getItem('condo_user_name') || 'Utilizador';
});
const [currentUserId, setCurrentUserId] = useState(() => {
return sessionStorage.getItem('condo_user_id') || '0';
});
const [userStatus, setUserStatus] = useState(() => {
return sessionStorage.getItem('condo_user_status') || 'aprovado';
});
const [authView, setAuthView] = useState('login');
const handleRegister = async (data) => {
// Verificação na lista atual de moradores para prevenir duplicados locais
const emailExists = residents.some(r => r.email && r.email.toLowerCase() === data.email.toLowerCase());
if (emailExists) {
return { success: false, message: 'Este email já se encontra registado no sistema.' };
}
try {
const userCredential = await createUserWithEmailAndPassword(auth, data.email, data.password);
const userId = userCredential.user.uid;
await set(ref(db, `condominos/${userId}`), {
id: userId,
name: data.name,
email: data.email,
contact: data.phone,
dob: data.dob,
cc: data.cc,
nif: data.nif,
role: 'morador',
status: 'pendente',
unit: data.unit,
pending: 0
});
sessionStorage.setItem('condo_auth', 'true');
sessionStorage.setItem('condo_role', 'morador');
sessionStorage.setItem('condo_user_name', data.name);
sessionStorage.setItem('condo_user_id', userId);
sessionStorage.setItem('condo_user_status', 'pendente');
setIsAuthenticated(true);
setUserRole('morador');
setCurrentUserName(data.name);
setCurrentUserId(userId);
setUserStatus('pendente');
return { success: true };
} catch (error) {
console.error("Erro no registo Firebase:", error);
if (error.code === 'auth/email-already-in-use') {
return { success: false, message: 'Este email já está associado a outra conta.' };
}
if (error.code === 'auth/weak-password') {
return { success: false, message: 'A palavra-passe deve ter pelo menos 6 caracteres.' };
}
if (error.code === 'auth/invalid-email') {
return { success: false, message: 'O formato do email é inválido.' };
}
// Se falhar devido a falta de configuração, simula registo local (fallback)
console.log("A executar fallback local de registo...");
const localId = 'local_' + Date.now();
try {
await set(ref(db, `condominos/${localId}`), {
id: localId,
name: data.name,
email: data.email,
contact: data.phone,
dob: data.dob,
cc: data.cc,
nif: data.nif,
role: 'morador',
status: 'pendente',
unit: data.unit,
pending: 0,
contact: data.password // Guardado no campo contact apenas para o fallback mock local de login funcionar
});
} catch(dbErr) {
console.error("Base de dados inacessível no fallback.", dbErr);
}
sessionStorage.setItem('condo_auth', 'true');
sessionStorage.setItem('condo_role', 'morador');
sessionStorage.setItem('condo_user_name', data.name);
sessionStorage.setItem('condo_user_id', localId);
sessionStorage.setItem('condo_user_status', 'pendente');
setIsAuthenticated(true);
setUserRole('morador');
setCurrentUserName(data.name);
setCurrentUserId(localId);
setUserStatus('pendente');
return { success: true };
}
};
const handleLogin = async (email, password) => {
try {
const userCredential = await signInWithEmailAndPassword(auth, email, password);
let role = 'morador';
let userName = 'Utilizador';
let userId = userCredential.user.uid;
let status = 'aprovado';
if (email.toLowerCase().includes('admin')) {
role = 'admin';
userName = 'Administração';
} else {
const residentUser = residents.find(r => r.email && r.email.toLowerCase() === email.toLowerCase());
const residentUser = residents.find(r => r.id === userId || (r.email && r.email.toLowerCase() === email.toLowerCase()));
if (residentUser) {
role = residentUser.role || 'morador';
userName = residentUser.name + (residentUser.unit && residentUser.unit !== 'Pendente' ? ` (${residentUser.unit})` : '');
userId = residentUser.id || userId;
status = residentUser.status || 'aprovado';
} else {
status = 'pendente'; // Fallback if missing
}
}
sessionStorage.setItem('condo_auth', 'true');
sessionStorage.setItem('condo_role', role);
sessionStorage.setItem('condo_user_name', userName);
sessionStorage.setItem('condo_user_id', userId);
sessionStorage.setItem('condo_user_status', status);
setIsAuthenticated(true);
setUserRole(role);
setCurrentUserName(userName);
setCurrentUserId(userId);
setUserStatus(status);
return true;
} catch (error) {
console.log("Firebase Auth falhou, a tentar conta local...", error);
let role = null;
let userName = 'Utilizador';
let userId = 'local_' + Date.now();
let status = 'aprovado';
if (email === 'administradores@gmail.com' && password === 'admin123') {
role = 'admin';
} else if (email === 'moradores@gmail.com' && password === 'moradores123') {
role = 'morador';
userName = 'Administração';
userId = 'admin_001';
} else {
const residentUser = residents.find(r => r.email && r.email.toLowerCase() === email.toLowerCase());
if (residentUser && (password === residentUser.contact || password === '1234')) {
role = residentUser.role || 'morador';
userName = residentUser.name + (residentUser.unit && residentUser.unit !== 'Pendente' ? ` (${residentUser.unit})` : '');
userId = residentUser.id || userId;
status = residentUser.status || 'aprovado';
}
}
if (role) {
sessionStorage.setItem('condo_auth', 'true');
sessionStorage.setItem('condo_role', role);
sessionStorage.setItem('condo_user_name', userName);
sessionStorage.setItem('condo_user_id', userId);
sessionStorage.setItem('condo_user_status', status);
setIsAuthenticated(true);
setUserRole(role);
setCurrentUserName(userName);
setCurrentUserId(userId);
setUserStatus(status);
return true;
}
return false;
@@ -379,8 +626,14 @@
if (window.confirm('Tem a certeza que deseja terminar sessão?')) {
sessionStorage.removeItem('condo_auth');
sessionStorage.removeItem('condo_role');
sessionStorage.removeItem('condo_user_name');
sessionStorage.removeItem('condo_user_id');
sessionStorage.removeItem('condo_user_status');
setIsAuthenticated(false);
setUserRole(null);
setCurrentUserName('Utilizador');
setCurrentUserId('0');
setUserStatus('aprovado');
setActiveTab('dashboard');
}
};
@@ -390,6 +643,9 @@
const [issues, setIssues] = useState([]);
const [bookings, setBookings] = useState([]);
const [invoices, setInvoices] = useState([]);
const [messages, setMessages] = useState([]);
const [newMessageText, setNewMessageText] = useState('');
const [activeChat, setActiveChat] = useState({ type: 'global', id: 'global', name: 'Fórum do Condomínio' });
useEffect(() => {
const loadData = (path, setter, sortFunc = null) => {
@@ -415,6 +671,26 @@
unsubResidents(); unsubFinances(); unsubIssues(); unsubBookings(); unsubInvoices();
};
}, []);
useEffect(() => {
let path = 'mural_mensagens';
if (activeChat.type === 'private') {
path = `mensagens_privadas/${[currentUserId, activeChat.id].sort().join('_')}`;
}
const unsub = onValue(ref(db, path), (snapshot) => {
const data = snapshot.val();
if (data) {
let parsed = Object.entries(data).map(([id, val]) => ({ id, ...val }));
parsed = parsed.sort((a,b) => a.timestamp - b.timestamp);
setMessages(parsed);
} else {
setMessages([]);
}
}, (error) => console.error(`Erro ao carregar mensagens de ${path}:`, error));
return () => unsub();
}, [activeChat, currentUserId]);
const [notificationsList, setNotificationsList] = useState(INITIAL_NOTIFICATIONS);
const [isNotificationsOpen, setNotificationsOpen] = useState(false);
@@ -881,12 +1157,20 @@
<h3 className="font-bold text-lg text-slate-800 dark:text-white">Manutenção e Ocorrências</h3>
<p className="text-sm text-slate-500 dark:text-dark-mute">Gestão de pedidos e reparações</p>
</div>
<button
onClick={() => handleOpenModal('issue')}
className="bg-orange-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-orange-700 flex items-center gap-2 shadow-sm transition-colors"
>
<Plus size={18} /> Reportar Problema
</button>
{userRole === 'admin' ? (
<button
className="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 shadow-sm transition-colors cursor-default"
>
<CheckCircle size={18} /> Resolver Problemas
</button>
) : (
<button
onClick={() => handleOpenModal('issue')}
className="bg-orange-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-orange-700 flex items-center gap-2 shadow-sm transition-colors"
>
<Plus size={18} /> Reportar Problema
</button>
)}
</div>
<div className="flex-1 overflow-auto p-6 bg-slate-50 dark:bg-dark-bg">
@@ -1117,7 +1401,13 @@
};
if (!isAuthenticated) {
return <LoginView onLogin={handleLogin} />;
return authView === 'login'
? <LoginView onLogin={handleLogin} onToggleView={() => setAuthView('register')} />
: <RegisterView onRegister={handleRegister} onToggleView={() => setAuthView('login')} />;
}
if (userStatus === 'pendente') {
return <WaitingApprovalView onLogout={handleLogout} />;
}
return (
@@ -1149,6 +1439,7 @@
{userRole === 'admin' && <SidebarItem icon={Users} label="Condóminos" active={activeTab === 'residents'} onClick={() => { setActiveTab('residents'); setSidebarOpen(false); }} />}
{userRole === 'admin' && <SidebarItem icon={Wallet} label="Finanças" active={activeTab === 'finance'} onClick={() => { setActiveTab('finance'); setSidebarOpen(false); }} />}
{userRole === 'admin' && <SidebarItem icon={FileText} label="Faturação" active={activeTab === 'billing'} onClick={() => { setActiveTab('billing'); setSidebarOpen(false); }} />}
{userRole === 'admin' && <SidebarItem icon={Users} label="Aprovações" active={activeTab === 'approvals'} onClick={() => { setActiveTab('approvals'); setSidebarOpen(false); }} />}
<SidebarItem icon={Wrench} label="Manutenção" active={activeTab === 'maintenance'} onClick={() => { setActiveTab('maintenance'); setSidebarOpen(false); }} />
<SidebarItem icon={MessageCircle} label="Mensagens" active={activeTab === 'messages'} onClick={() => { setActiveTab('messages'); setSidebarOpen(false); }} />
<SidebarItem icon={Map} label="Mapa" active={activeTab === 'map'} onClick={() => { setActiveTab('map'); setSidebarOpen(false); }} />
@@ -1340,6 +1631,70 @@
</div>
)}
{/* --- APPROVALS --- */}
{activeTab === 'approvals' && userRole === 'admin' && (
<div className="p-6">
<div className="mb-6">
<h2 className="text-2xl font-bold text-slate-800 dark:text-white">Aprovações de Moradores</h2>
<p className="text-slate-500 dark:text-dark-mute">Valide ou rejeite os novos pedidos de registo na plataforma.</p>
</div>
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-sm border border-slate-100 dark:border-dark-border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50 dark:bg-dark-bg border-b border-slate-100 dark:border-dark-border text-sm font-semibold text-slate-500 dark:text-slate-400">
<th className="p-4">Morador</th>
<th className="p-4">NIF / CC</th>
<th className="p-4">Contacto</th>
<th className="p-4">Data Nasc.</th>
<th className="p-4 text-center">Ações</th>
</tr>
</thead>
<tbody>
{residents.filter(r => r.status === 'pendente').map(req => (
<tr key={req.id} className="border-b border-slate-50 dark:border-dark-border hover:bg-slate-50/50 dark:hover:bg-dark-bg/50">
<td className="p-4">
<p className="font-semibold text-slate-700 dark:text-slate-200">{req.name}</p>
<p className="text-xs text-slate-400">{req.email}</p>
</td>
<td className="p-4 text-slate-600 dark:text-slate-400">
<p className="text-sm">NIF: {req.nif}</p>
<p className="text-sm">CC: {req.cc}</p>
</td>
<td className="p-4 text-sm text-slate-600 dark:text-slate-400">{req.contact}</td>
<td className="p-4 text-sm text-slate-600 dark:text-slate-400">{req.dob}</td>
<td className="p-4">
<div className="flex justify-center gap-2">
<button onClick={() => {
if(window.confirm('Aprovar este morador?')) {
set(ref(db, `condominos/${req.id}/status`), 'aprovado');
showNotification('Morador aprovado com sucesso!', 'success');
}
}} className="p-2 bg-green-100 text-green-600 rounded hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400" title="Aprovar">
<CheckCircle size={18} />
</button>
<button onClick={() => {
if(window.confirm('Rejeitar este pedido? O registo será eliminado.')) {
remove(ref(db, `condominos/${req.id}`));
showNotification('Registo eliminado.', 'success');
}
}} className="p-2 bg-red-100 text-red-600 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400" title="Rejeitar">
<Trash2 size={18} />
</button>
</div>
</td>
</tr>
))}
{residents.filter(r => r.status === 'pendente').length === 0 && (
<tr><td colSpan="5" className="p-8 text-center text-slate-500">Nenhum pedido pendente de aprovação.</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* --- RESIDENTS --- */}
{activeTab === 'residents' && (
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-sm border border-slate-100 dark:border-dark-border overflow-hidden animate-fade-in flex flex-col h-full transition-colors">
@@ -1587,7 +1942,10 @@
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-3 border-b-2 border-blue-100 dark:border-blue-900/30 bg-blue-50/50 dark:bg-blue-900/10 cursor-pointer hover:bg-slate-50 dark:hover:bg-dark-card transition-colors">
<div
onClick={() => setActiveChat({ type: 'global', id: 'global', name: 'Fórum do Condomínio' })}
className={`p-3 border-b-2 cursor-pointer transition-colors ${activeChat.type === 'global' ? 'border-blue-100 dark:border-blue-900/30 bg-blue-50/50 dark:bg-blue-900/10 hover:bg-blue-50/80 dark:hover:bg-blue-900/20' : 'border-transparent hover:bg-slate-50 dark:hover:bg-dark-card'}`}
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-blue-600 dark:text-blue-400 font-bold shrink-0">
<Users size={20} />
@@ -1595,24 +1953,28 @@
<div className="flex-1 min-w-0">
<div className="flex justify-between items-baseline mb-0.5">
<h4 className="text-sm font-bold text-slate-800 dark:text-slate-200 truncate">Fórum do Condomínio</h4>
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium whitespace-nowrap">Agora</span>
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium whitespace-nowrap">Geral</span>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">Administração: Reunião na próxima sexta.</p>
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">Grupo partilhado</p>
</div>
</div>
</div>
{residents.slice(0, 4).map(res => (
<div key={res.id} className="p-3 border-b border-slate-50 dark:border-dark-border/50 cursor-pointer hover:bg-slate-50 dark:hover:bg-dark-card transition-colors">
{residents.filter(r => r.id !== currentUserId).map(res => (
<div
key={res.id}
onClick={() => setActiveChat({ type: 'private', id: res.id, name: res.name })}
className={`p-3 border-b border-slate-50 dark:border-dark-border/50 cursor-pointer transition-colors ${activeChat.id === res.id ? 'bg-blue-50/50 dark:bg-blue-900/10' : 'hover:bg-slate-50 dark:hover:bg-dark-card'}`}
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-300 font-bold shrink-0 text-sm">
{res.name.substring(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-baseline mb-0.5">
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 truncate">{res.name} ({res.unit})</h4>
<span className="text-xs text-slate-400 whitespace-nowrap">Ontem</span>
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 truncate">{res.name} {res.unit && `(${res.unit})`}</h4>
{activeChat.id === res.id && <span className="w-2 h-2 rounded-full bg-blue-500"></span>}
</div>
<p className="text-xs text-slate-500 dark:text-slate-500 truncate">Tudo bem, tratamos disso!</p>
<p className="text-xs text-slate-500 dark:text-slate-500 truncate">Morador</p>
</div>
</div>
</div>
@@ -1624,55 +1986,75 @@
<div className="flex-1 flex flex-col h-full bg-slate-50/50 dark:bg-dark-bg/50">
<div className="p-4 border-b border-slate-100 dark:border-dark-border bg-white dark:bg-dark-surface flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-blue-600 dark:text-blue-400 font-bold">
<Users size={20} />
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold ${activeChat.type === 'global' ? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400' : 'bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-300'}`}>
{activeChat.type === 'global' ? <Users size={20} /> : activeChat.name.substring(0, 2).toUpperCase()}
</div>
<div>
<h3 className="font-bold text-slate-800 dark:text-white">Fórum do Condomínio</h3>
<p className="text-xs text-green-500 font-medium">Todos os moradores</p>
<h3 className="font-bold text-slate-800 dark:text-white">{activeChat.name}</h3>
<p className="text-xs text-green-500 font-medium">{activeChat.type === 'global' ? 'Todos os moradores' : 'Privado'}</p>
</div>
</div>
<button className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200"><Info size={20}/></button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="text-center my-4"><span className="text-xs bg-slate-200/50 dark:bg-slate-800 text-slate-500 px-3 py-1 rounded-full">Hoje</span></div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 flex flex-col">
<div className="text-center my-4"><span className="text-xs bg-slate-200/50 dark:bg-slate-800 text-slate-500 px-3 py-1 rounded-full">Mensagens</span></div>
<div className="flex justify-start">
<div className="bg-white dark:bg-dark-card border border-slate-100 dark:border-slate-800 text-slate-700 dark:text-slate-200 p-3 rounded-2xl rounded-tl-sm max-w-[80%] shadow-sm">
<p className="text-xs text-blue-600 dark:text-blue-400 font-bold mb-1">Administração</p>
<p className="text-sm">Bom dia a todos. Relembramos que a manutenção dos elevadores ocorrerá amanhã às 10h.</p>
<span className="text-[10px] text-slate-400 block text-right mt-1">09:00</span>
</div>
</div>
{messages.map((msg) => {
const isMe = msg.senderId === currentUserId;
const timeString = new Date(msg.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
<div className="flex justify-end">
<div className="bg-blue-600 text-white p-3 rounded-2xl rounded-tr-sm max-w-[80%] shadow-sm">
<p className="text-sm">Obrigado pelo aviso!</p>
<span className="text-[10px] text-blue-200 block text-right mt-1">09:15</span>
</div>
</div>
<div className="flex justify-start">
<div className="bg-white dark:bg-dark-card border border-slate-100 dark:border-slate-800 text-slate-700 dark:text-slate-200 p-3 rounded-2xl rounded-tl-sm max-w-[80%] shadow-sm">
<p className="text-xs text-orange-600 dark:text-orange-400 font-bold mb-1">Maria Pereira (2º Esq)</p>
<p className="text-sm">Alguém encontrou um porta-chaves com formato de gato na entrada do prédio?</p>
<span className="text-[10px] text-slate-400 block text-right mt-1">11:32</span>
</div>
</div>
return (
<div key={msg.id} className={`flex ${isMe ? 'justify-end' : 'justify-start'}`}>
<div className={`${isMe ? 'bg-blue-600 text-white rounded-2xl rounded-tr-sm' : 'bg-white dark:bg-dark-card border border-slate-100 dark:border-slate-800 text-slate-700 dark:text-slate-200 rounded-2xl rounded-tl-sm'} p-3 max-w-[80%] shadow-sm`}>
{!isMe && (
<p className={`text-xs ${msg.role === 'admin' ? 'text-blue-600 dark:text-blue-400' : 'text-orange-600 dark:text-orange-400'} font-bold mb-1`}>
{msg.senderName}
</p>
)}
<p className="text-sm">{msg.text}</p>
<span className={`text-[10px] ${isMe ? 'text-blue-200' : 'text-slate-400'} block text-right mt-1`}>{timeString}</span>
</div>
</div>
);
})}
</div>
<div className="p-4 bg-white dark:bg-dark-surface border-t border-slate-100 dark:border-dark-border">
<form onSubmit={(e) => { e.preventDefault(); showNotification('A sua mensagem foi enviada!', 'success'); e.target.reset(); }} className="flex gap-2">
<form onSubmit={async (e) => {
e.preventDefault();
if (!newMessageText.trim()) return;
try {
const path = activeChat.type === 'global'
? 'mural_mensagens'
: `mensagens_privadas/${[currentUserId, activeChat.id].sort().join('_')}`;
const newMsgRef = push(ref(db, path));
await set(newMsgRef, {
text: newMessageText,
senderId: currentUserId,
senderName: currentUserName,
role: userRole,
timestamp: Date.now()
});
setNewMessageText('');
} catch (error) {
console.error("Erro ao enviar mensagem:", error);
showNotification("Erro ao enviar mensagem.", "error");
}
}} className="flex gap-2">
<button type="button" className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors hidden sm:block">
<Paperclip size={20} />
</button>
<input
type="text"
value={newMessageText}
onChange={(e) => setNewMessageText(e.target.value)}
placeholder="Escreva a sua mensagem..."
className="flex-1 bg-slate-50 dark:bg-dark-bg border border-slate-200 dark:border-dark-border rounded-full px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:text-white"
/>
<button type="submit" className="p-2 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors shadow-sm flex items-center justify-center shrink-0 w-10 h-10">
<button type="submit" disabled={!newMessageText.trim()} className="p-2 bg-blue-600 text-white rounded-full hover:bg-blue-700 disabled:bg-blue-400 transition-colors shadow-sm flex items-center justify-center shrink-0 w-10 h-10">
<Send size={18} />
</button>
</form>