import React, { useState, useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import {
Building2, Users, Wallet, Wrench, Bell, Search, Plus, Menu, X,
TrendingUp, TrendingDown, CheckCircle, AlertCircle, Clock, LogOut,
Edit2, Trash2, Save, Filter, MoreVertical, FileText,
Dumbbell, PartyPopper, Trophy, Map, Calendar, MapPin, Info,
MessageCircle, Paperclip, Send, Store, HeartPulse, Waves, ShoppingCart, Navigation, Car
} from 'lucide-react';
import { app } from './firebase.js';
import { getAuth, signInWithEmailAndPassword, createUserWithEmailAndPassword } from 'https://www.gstatic.com/firebasejs/12.1.0/firebase-auth.js';
import { getDatabase, ref, push, set, onValue, remove, update } from 'https://www.gstatic.com/firebasejs/12.1.0/firebase-database.js';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
this.setState({ error, errorInfo });
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
Algo correu mal (Erro na Aplicação)
{this.state.error && this.state.error.toString()}
{this.state.errorInfo && this.state.errorInfo.componentStack}
);
}
return this.props.children;
}
}
const auth = getAuth(app);
const db = getDatabase(app);
const INITIAL_RESIDENTS = [
{ id: 1, unit: '1º Esq', name: 'Ana Silva', contact: '912 345 678', email: 'ana.silva@email.com', status: 'Pago', pending: 0, role: 'morador' },
{ id: 2, unit: '1º Dto', name: 'Carlos Santos', contact: '965 432 109', email: 'carlos.s@email.com', status: 'Pendente', pending: 45.00, role: 'morador' },
{ id: 3, unit: '2º Esq', name: 'Maria Pereira', contact: '933 221 110', email: 'maria.p@email.com', status: 'Pago', pending: 0, role: 'morador' },
{ id: 4, unit: '2º Dto', name: 'João Ferreira', contact: '918 765 432', email: 'joao.f@email.com', status: 'Atrasado', pending: 135.00, role: 'morador' },
{ id: 5, unit: '3º Esq', name: 'Sofia Costa', contact: '922 334 455', email: 'sofia.c@email.com', status: 'Pago', pending: 0, role: 'morador' },
];
const INITIAL_FINANCES = [
{ id: 1, type: 'income', category: 'Quotas Mensais', date: '2023-10-01', amount: 2250.00, desc: 'Pagamento de quotas Outubro' },
{ id: 2, type: 'expense', category: 'Limpeza', date: '2023-10-02', amount: 450.00, desc: 'Serviço de Limpeza Semanal' },
{ id: 3, type: 'expense', category: 'Elevadores', date: '2023-10-05', amount: 120.00, desc: 'Manutenção Mensal' },
{ id: 4, type: 'income', category: 'Aluguer Salão', date: '2023-10-10', amount: 50.00, desc: 'Reserva 2º Dto' },
{ id: 5, type: 'expense', category: 'Jardinagem', date: '2023-10-12', amount: 85.00, desc: 'Poda de árvores' },
];
const INITIAL_ISSUES = [
{ id: 1, title: 'Lâmpada fundida no Hall', location: 'R/C', status: 'Novo', priority: 'Baixa', date: '2023-10-15' },
{ id: 2, title: 'Porta da garagem não fecha', location: 'Garagem -1', status: 'Em Progresso', priority: 'Alta', date: '2023-10-14' },
{ id: 3, title: 'Infiltração no teto', location: '3º Dto', status: 'Resolvido', priority: 'Média', date: '2023-10-10' },
];
const INITIAL_BOOKINGS = [
{ id: 1, facility: 'hall', facilityName: 'Salão de Festas', date: '2023-10-25', time: '14:00 - 20:00', resident: 'Ana Silva', status: 'Confirmado', cost: 50 },
{ id: 2, facility: 'gym', facilityName: 'Ginásio', date: '2023-10-20', time: '09:00 - 10:00', resident: 'Carlos Santos', status: 'Confirmado', cost: 0 },
{ id: 3, facility: 'park', facilityName: 'Parque de Jogos', date: '2023-10-22', time: '18:00 - 19:00', resident: 'Sofia Costa', status: 'Pendente', cost: 10 },
];
const INITIAL_NOTIFICATIONS = [
{ id: 1, message: 'Nova reserva: Salão de Festas (25 Out)', time: 'Há 1 hora', type: 'info', read: false },
{ id: 2, message: 'Nova quota paga: Maria Pereira', time: 'Há 2 horas', type: 'success', read: false },
{ id: 3, message: 'Manutenção urgente reportada', time: 'Há 5 horas', type: 'warning', read: false },
];
// --- VALIDAÇÕES OFICIAIS ---
function validarNIF(nif) {
nif = String(nif).replace(/\s+/g, '');
return /^\d{9}$/.test(nif);
}
function validarDocumento(doc) {
let docStr = doc.replace(/[\s-]/g, '').toUpperCase();
// Muitos utilizadores inserem apenas os 8 dígitos do NIC, o que não tem check-digit na própria string
if (/^\d{8}$/.test(docStr)) {
return true;
}
if (/^\d{9}$/.test(docStr)) {
let checkDigitValue = parseInt(docStr.charAt(docStr.length - 1), 10);
let soma = 0;
for (let i = 0; i < docStr.length - 1; i++) {
soma += parseInt(docStr.charAt(i), 10) * (docStr.length - i);
}
let resto = soma % 11;
let expectedDigit = (resto === 0 || resto === 1) ? 0 : (11 - resto);
return expectedDigit === checkDigitValue;
}
if (docStr.length === 12) {
let sum = 0;
let isSecond = false;
for (let i = docStr.length - 1; i >= 0; i--) {
let charCode = docStr.charCodeAt(i);
let val = 0;
if (charCode >= 48 && charCode <= 57) val = charCode - 48;
else if (charCode >= 65 && charCode <= 90) val = charCode - 55;
else return false;
if (isSecond) {
val *= 2;
if (val >= 36) val -= 36;
}
sum += val;
isSecond = !isSecond;
}
return (sum % 36) === 0;
}
return false;
}
const Modal = ({ isOpen, onClose, title, children }) => {
if (!isOpen) return null;
return (
);
};
const InputGroup = ({ label, name, type = 'text', value, onChange, placeholder, required = false, options = null, disabled = false }) => (
{options ? (
) : (
)}
);
const SidebarItem = ({ icon: Icon, label, active, onClick }) => (
);
const Card = ({ title, value, icon: Icon, trend, trendValue, color, subtitle }) => (
{trend === 'up' ? (
{trendValue}
) : trend === 'down' ? (
{trendValue}
) : (
—
)}
{subtitle || 'vs. mês passado'}
);
const Badge = ({ status }) => {
const styles = {
'Pago': 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800',
'Em dia': 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800',
'Resolvido': 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800',
'Receita': 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800',
'Confirmado': 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800',
'Pendente': 'bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400 dark:border-yellow-800',
'Em Validação': 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:border-orange-800',
'Em Progresso': 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800',
'Média': 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:border-orange-800',
'Atrasado': 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800',
'Despesa': 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800',
'Alta': 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800',
'Novo': 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:border-purple-800',
'Baixa': 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800',
};
return (
{status}
);
};
const LoginView = ({ onLogin }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (isLoading) return;
setError('');
setIsLoading(true);
const success = await onLogin(email, password);
setIsLoading(false);
if (!success) {
setError('Email ou Palavra-passe incorreta');
setTimeout(() => {
setError('');
}, 5000);
}
};
return (
MyCondominium
Portal de Gestão
);
};
function App() {
const [activeTab, setActiveTab] = useState('dashboard');
const [isSidebarOpen, setSidebarOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [theme, setTheme] = useState('system');
const [isAuthenticated, setIsAuthenticated] = useState(() => {
return sessionStorage.getItem('condo_auth') === 'true';
});
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 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.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';
userName = 'Administração';
userId = 'admin_001';
} else {
const residentUser = residents.find(r => r.email && r.email.toLowerCase() === email.toLowerCase());
if (residentUser && (password === residentUser.password || (!residentUser.password && 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;
}
};
const handleLogout = () => {
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');
}
};
const [residents, setResidents] = useState([]);
const [finances, setFinances] = useState([]);
const [issues, setIssues] = useState([]);
const [bookings, setBookings] = useState([]);
const [invoices, setInvoices] = useState([]);
const [faturas, setFaturas] = useState([]);
const [messages, setMessages] = useState([]);
const [newMessageText, setNewMessageText] = useState('');
const [activeChat, setActiveChat] = useState({ type: 'global', id: 'global', name: 'Fórum do Condomínio' });
const [chatGroups, setChatGroups] = useState([]);
const [isCreateGroupModalOpen, setIsCreateGroupModalOpen] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [newGroupMembers, setNewGroupMembers] = useState([]);
useEffect(() => {
const loadData = (path, setter, sortFunc = null) => {
return onValue(ref(db, path), (snapshot) => {
const data = snapshot.val();
if (data) {
let parsed = Object.entries(data).map(([id, val]) => {
if (path === 'faturas' && val.status === 'Em Validação') {
return { id, ...val, status: 'Pago' };
}
return { id, ...val };
});
if (userRole !== 'admin' && (path === 'manutencao' || path === 'reservas')) {
parsed = parsed.filter(item => item.moradorId === currentUserId);
}
if (sortFunc) parsed = parsed.sort(sortFunc);
setter(parsed);
} else {
setter([]);
}
}, (error) => console.error(`Erro ao carregar ${path}:`, error));
};
const unsubResidents = loadData('condominos', setResidents);
const unsubFinances = loadData('financas', setFinances, (a,b) => new Date(b.date) - new Date(a.date));
const unsubIssues = loadData('manutencao', setIssues, (a,b) => new Date(b.date) - new Date(a.date));
const unsubBookings = loadData('reservas', setBookings, (a,b) => new Date(a.date) - new Date(b.date));
const unsubInvoices = loadData('faturacao', setInvoices, (a,b) => new Date(b.date) - new Date(a.date));
const unsubFaturas = loadData('faturas', setFaturas, (a,b) => new Date(b.dataVencimento) - new Date(a.dataVencimento));
const unsubGroups = loadData('grupos_chat', setChatGroups);
return () => {
unsubResidents();
unsubFinances();
unsubIssues();
unsubBookings();
unsubInvoices();
unsubFaturas();
unsubGroups();
};
}, []);
useEffect(() => {
if (!isAuthenticated || !currentUserId) {
setNotificationsList([]);
return;
}
const targetFolder = userRole === 'admin' ? 'admin' : currentUserId;
const path = `notificacoes/${targetFolder}`;
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) => b.timestamp - a.timestamp);
setNotificationsList(parsed);
} else {
setNotificationsList([]);
}
}, (error) => console.error(`Erro ao carregar notificações:`, error));
return () => unsub();
}, [isAuthenticated, currentUserId, userRole]);
useEffect(() => {
let path = 'mural_mensagens';
if (activeChat.type === 'private') {
path = `mensagens_privadas/${[currentUserId, activeChat.id].sort().join('_')}`;
} else if (activeChat.type === 'group') {
path = `mensagens_grupo/${activeChat.id}`;
}
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]);
useEffect(() => {
if (userRole === 'admin' && residents.length > 0 && faturas.length > 0) {
let hasUpdates = false;
const updates = {};
residents.forEach((resident) => {
const residentFaturas = faturas.filter(f => f.moradorId === resident.id && f.status !== 'Pago');
const actualPending = residentFaturas.reduce((acc, f) => acc + Number(f.valor), 0);
const actualStatus = actualPending > 0 ? 'Pendente' : 'Pago';
if (Number(resident.pending) !== actualPending || resident.status !== actualStatus) {
updates[`condominos/${resident.id}/pending`] = actualPending;
updates[`condominos/${resident.id}/status`] = actualStatus;
hasUpdates = true;
}
});
if (hasUpdates) {
update(ref(db), updates).catch(err => console.error("Erro na sincronização:", err));
}
}
}, [faturas, residents, userRole]);
const [notificationsList, setNotificationsList] = useState([]);
const [isNotificationsOpen, setNotificationsOpen] = useState(false);
const [activeModal, setActiveModal] = useState(null);
const [editingItem, setEditingItem] = useState(null);
const [notification, setNotification] = useState(null);
const notificationRef = useRef(null);
const initialResidentForm = { unit: '', name: '', contact: '', email: '', status: 'Pago', pending: 0, role: 'morador' };
const initialFinanceForm = { type: 'expense', category: '', amount: '', desc: '', date: new Date().toISOString().split('T')[0] };
const initialIssueForm = { title: '', location: '', priority: 'Média', status: 'Novo', date: new Date().toISOString().split('T')[0] };
const initialBookingForm = { facility: 'gym', date: new Date().toISOString().split('T')[0], time: '', resident: '', cost: 0 };
const initialFaturaForm = { moradorId: '', categoria: '', valor: '', dataVencimento: new Date().toISOString().split('T')[0] };
const [formData, setFormData] = useState({});
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('dark');
if (theme === 'dark') {
root.classList.add('dark');
} else if (theme === 'system') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
root.classList.add('dark');
}
}
}, [theme]);
useEffect(() => {
if (notification) {
const timer = setTimeout(() => setNotification(null), 3000);
return () => clearTimeout(timer);
}
}, [notification]);
useEffect(() => {
function handleClickOutside(event) {
if (notificationRef.current && !notificationRef.current.contains(event.target)) {
setNotificationsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [notificationRef]);
const toggleSidebar = () => setSidebarOpen(!isSidebarOpen);
const totalIncome = finances.filter(f => f.type === 'income').reduce((acc, curr) => acc + Number(curr.amount), 0);
const totalExpense = finances.filter(f => f.type === 'expense').reduce((acc, curr) => acc + Number(curr.amount), 0);
const balance = totalIncome - totalExpense;
const activeIssuesCount = issues.filter(i => i.status !== 'Resolvido').length;
const unreadNotifications = notificationsList.filter(n => !n.read).length;
const filteredResidents = residents.filter(r =>
r.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.unit.toLowerCase().includes(searchQuery.toLowerCase())
);
const sendSystemNotification = async (message, type = 'info', targetUserId = 'admin') => {
const newNotif = { timestamp: Date.now(), message, time: 'Agora', type, read: false };
if (targetUserId === 'todos') {
const promises = residents.map(r => push(ref(db, `notificacoes/${r.id}`), newNotif));
promises.push(push(ref(db, `notificacoes/admin`), newNotif));
await Promise.all(promises);
} else {
await push(ref(db, `notificacoes/${targetUserId}`), newNotif);
}
};
const handleMarkAsRead = async (notifId) => {
const targetFolder = userRole === 'admin' ? 'admin' : currentUserId;
const notifRef = ref(db, `notificacoes/${targetFolder}/${notifId}`);
await update(notifRef, { read: true });
};
const showNotification = (message, type = 'success') => {
setNotification({ message, type });
};
const handleClearNotifications = async () => {
const targetFolder = userRole === 'admin' ? 'admin' : currentUserId;
await set(ref(db, `notificacoes/${targetFolder}`), null);
setNotificationsOpen(false);
};
const handleOpenModal = (type, item = null, defaultFacility = null) => {
setEditingItem(item);
setActiveModal(type);
if (type === 'resident') {
setFormData(item || initialResidentForm);
} else if (type === 'finance') {
setFormData(initialFinanceForm);
} else if (type === 'issue') {
setFormData(initialIssueForm);
} else if (type === 'emitir_fatura') {
setFormData(initialFaturaForm);
} else if (type === 'booking') {
const baseForm = { ...initialBookingForm };
if (defaultFacility) baseForm.facility = defaultFacility;
// Preenche sempre o nome do utilizador logado por defeito
baseForm.resident = currentUserName;
setFormData(baseForm);
}
};
const handleCloseModal = () => {
setActiveModal(null);
setEditingItem(null);
setFormData({});
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => {
const newData = { ...prev, [name]: value };
if (name === 'facility' || name === 'time') {
let cost = 0;
if (newData.facility === 'hall') cost = 50;
if (newData.facility === 'park') cost = 10;
newData.cost = cost;
}
return newData;
});
};
const handleToggleRole = async (id) => {
try {
const resident = residents.find(r => r.id === id);
if (resident) {
const newRole = resident.role === 'admin' ? 'morador' : 'admin';
const residentRef = ref(db, `condominos/${id}`);
await set(residentRef, { ...resident, role: newRole });
showNotification('Permissões de utilizador atualizadas', 'success');
}
} catch (error) {
console.error("Erro ao atualizar permissão:", error);
showNotification("Erro ao atualizar permissão.", "error");
}
};
const handleSaveResident = async (e) => {
e.preventDefault();
try {
if (editingItem) {
const residentRef = ref(db, `condominos/${editingItem.id}`);
const updatedData = {
...editingItem,
unit: formData.unit || '',
name: formData.name || '',
contact: formData.contact || '',
email: formData.email || '',
status: formData.status || 'Pago',
pending: Number(formData.pending) || 0,
role: formData.role || 'morador'
};
if (formData.password) {
updatedData.password = formData.password;
}
await set(residentRef, updatedData);
showNotification(`Condómino ${formData.name} atualizado`);
} else {
const residentsListRef = ref(db, 'condominos');
const newResidentRef = push(residentsListRef);
await set(newResidentRef, {
unit: formData.unit || '',
name: formData.name || '',
contact: formData.contact || '',
email: formData.email || '',
password: formData.password || '1234',
status: formData.status || 'Pago',
pending: Number(formData.pending) || 0,
role: formData.role || 'morador'
});
showNotification(`Novo condómino ${formData.name} adicionado`);
}
handleCloseModal();
} catch (error) {
console.error("Erro ao guardar no Firebase:", error);
showNotification("Erro ao guardar os dados.", "error");
}
};
const handleDeleteResident = async (id) => {
if (window.confirm('Tem a certeza que deseja eliminar este condómino?')) {
try {
const residentRef = ref(db, `condominos/${id}`);
await remove(residentRef);
showNotification('Condómino removido', 'error');
} catch (error) {
console.error("Erro ao eliminar no Firebase:", error);
showNotification("Erro ao eliminar.", "error");
}
}
};
const handleSaveFinance = async (e) => {
e.preventDefault();
if (!formData.amount || !formData.category || !formData.date) {
showNotification("Preencha todos os campos obrigatórios.", "error");
return;
}
try {
const amount = Number(formData.amount);
const newFinanceRef = push(ref(db, 'financas'));
await set(newFinanceRef, { ...formData, amount });
if (formData.type === 'expense') {
sendSystemNotification(`Nova despesa registada: ${formData.category} - ${amount.toFixed(2)}€`, 'warning', 'admin');
} else {
sendSystemNotification(`Nova receita registada: ${formData.category} - ${amount.toFixed(2)}€`, 'success', 'admin');
}
showNotification(`Movimento de ${amount}€ registado`);
handleCloseModal();
} catch (error) {
console.error("Erro ao guardar finanças:", error);
showNotification("Erro ao guardar movimento.", "error");
}
};
const handleSaveIssue = async (e) => {
e.preventDefault();
if (!formData.title || !formData.location) {
showNotification("Preencha todos os campos obrigatórios.", "error");
return;
}
try {
const newIssueRef = push(ref(db, 'manutencao'));
await set(newIssueRef, { ...formData, moradorId: currentUserId });
sendSystemNotification(`Nova ocorrência reportada: ${formData.title} (${formData.location})`, 'warning', 'admin');
if (userRole !== 'admin') {
sendSystemNotification(`A sua ocorrência "${formData.title}" foi reportada com sucesso.`, 'info', currentUserId);
}
showNotification('Nova ocorrência reportada', 'warning');
handleCloseModal();
} catch (error) {
console.error("Erro ao reportar ocorrência:", error);
showNotification("Erro ao reportar ocorrência.", "error");
}
};
const handleSaveFatura = async (e) => {
e.preventDefault();
if (!formData.moradorId || !formData.categoria || !formData.valor || !formData.dataVencimento) {
showNotification("Preencha todos os campos obrigatórios.", "error");
return;
}
try {
const morador = residents.find(r => r.id === formData.moradorId);
if (!morador) return;
const valor = Number(formData.valor);
const newFaturaRef = push(ref(db, 'faturas'));
await set(newFaturaRef, {
moradorId: morador.id,
nomeMorador: morador.name,
fracao: morador.unit,
categoria: formData.categoria,
valor: valor,
dataVencimento: formData.dataVencimento,
status: 'Pendente',
dataEmissao: new Date().toISOString().split('T')[0]
});
const newPending = (Number(morador.pending) || 0) + valor;
await update(ref(db, `condominos/${morador.id}`), {
pending: newPending,
status: 'Pendente'
});
sendSystemNotification(`Foi emitida uma nova fatura no valor de ${valor.toFixed(2)}€ (Categoria: ${formData.categoria})`, 'warning', morador.id);
sendSystemNotification(`Fatura de ${valor.toFixed(2)}€ emitida para ${morador.name} (${morador.unit})`, 'info', 'admin');
showNotification(`Fatura de ${valor.toFixed(2)}€ emitida para ${morador.name}`);
handleCloseModal();
} catch (error) {
console.error("Erro ao emitir fatura:", error);
showNotification("Erro ao emitir fatura.", "error");
}
};
const handlePayFatura = async (fatura) => {
try {
await set(ref(db, `faturas/${fatura.id}/status`), 'Pago');
const morador = residents.find(r => r.id === fatura.moradorId);
if (morador) {
let newPending = (Number(morador.pending) || 0) - Number(fatura.valor);
if (newPending <= 0.01) newPending = 0;
await update(ref(db, `condominos/${morador.id}`), {
pending: newPending,
status: newPending === 0 ? 'Pago' : 'Pendente'
});
}
sendSystemNotification(`O pagamento da sua fatura de ${fatura.categoria} foi concluído!`, 'success', fatura.moradorId);
sendSystemNotification(`Pagamento registado para a fatura de ${fatura.categoria} da fração ${morador?.unit || fatura.fracao}.`, 'success', 'admin');
showNotification("Pagamento efetuado com sucesso!", "success");
} catch (error) {
console.error("Erro ao pagar fatura:", error);
showNotification("Erro ao processar pagamento.", "error");
}
};
const handleApproveFatura = async (fatura) => {
try {
await set(ref(db, `faturas/${fatura.id}/status`), 'Pago');
const morador = residents.find(r => r.id === fatura.moradorId);
if (morador) {
let newPending = (Number(morador.pending) || 0) - Number(fatura.valor);
if (newPending <= 0.01) newPending = 0;
await update(ref(db, `condominos/${morador.id}`), {
pending: newPending,
status: newPending === 0 ? 'Pago' : 'Pendente'
});
}
sendSystemNotification(`O seu pagamento da fatura de ${fatura.categoria} foi aprovado!`, 'success', fatura.moradorId);
sendSystemNotification(`Pagamento aprovado para a fatura de ${fatura.categoria} da fração ${morador?.unit || fatura.fracao}.`, 'success', 'admin');
showNotification("Pagamento aprovado com sucesso!", "success");
} catch (error) {
console.error("Erro ao aprovar fatura:", error);
showNotification("Erro ao processar aprovação.", "error");
}
};
const handleResolveIssue = async (id) => {
try {
const issue = issues.find(i => i.id === id);
if (issue) {
await set(ref(db, `manutencao/${id}`), { ...issue, status: 'Resolvido' });
sendSystemNotification(`A manutenção "${issue.title}" foi concluída com sucesso.`, 'success', 'todos');
showNotification('Ocorrência resolvida com sucesso');
}
} catch (error) {
console.error("Erro ao resolver ocorrência:", error);
showNotification("Erro ao resolver ocorrência.", "error");
}
};
const handleSaveBooking = async (e) => {
e.preventDefault();
if (!formData.resident || !formData.date || !formData.time) {
showNotification("Preencha todos os campos obrigatórios.", "error");
return;
}
try {
const facilityNames = { 'gym': 'Ginásio', 'hall': 'Salão de Festas', 'park': 'Parque de Jogos' };
const bookingData = {
...formData,
facilityName: facilityNames[formData.facility],
status: 'Confirmado',
moradorId: currentUserId
};
const newBookingRef = push(ref(db, 'reservas'));
await set(newBookingRef, bookingData);
if (bookingData.cost > 0) {
const newIncomeRef = push(ref(db, 'financas'));
await set(newIncomeRef, {
type: 'income',
category: `Reserva: ${bookingData.facilityName}`,
date: bookingData.date,
amount: bookingData.cost,
desc: `Reserva por ${bookingData.resident}`
});
}
sendSystemNotification(`Nova reserva: ${bookingData.facilityName} a ${bookingData.date}`, 'info', 'admin');
if (userRole !== 'admin') {
sendSystemNotification(`A sua reserva para ${bookingData.facilityName} foi confirmada.`, 'success', currentUserId);
}
showNotification(`Reserva confirmada para ${bookingData.facilityName}`);
handleCloseModal();
} catch (error) {
console.error("Erro ao criar reserva:", error);
showNotification("Erro ao criar reserva.", "error");
}
};
const handleGenerateInvoice = async (resident) => {
if (resident.pending <= 0) {
showNotification(`Não há dívidas para a fração ${resident.unit}`, 'warning');
return;
}
try {
const newInvoiceRef = push(ref(db, 'faturacao'));
await set(newInvoiceRef, {
residentId: resident.id,
unit: resident.unit,
name: resident.name,
amount: Number(resident.pending),
date: new Date().toISOString().split('T')[0],
status: 'Emitida'
});
sendSystemNotification(`Foi emitida uma nova fatura instantânea no valor de ${Number(resident.pending).toFixed(2)}€`, 'warning', resident.id);
sendSystemNotification(`Fatura instantânea gerada para a fração ${resident.unit} no valor de ${Number(resident.pending).toFixed(2)}€`, 'info', 'admin');
showNotification(`Fatura instantânea gerada para a fração ${resident.unit}`, 'success');
} catch (error) {
console.error("Erro ao faturar:", error);
showNotification("Erro ao gerar fatura.", "error");
}
};
const DashboardView = () => (
{userRole === 'admin' ? (
= 0 ? 'up' : 'down'} trendValue="Atual" color="bg-blue-500" />
) : (
)}
Próximas Reservas
{bookings.slice(0, 4).map(booking => (
{booking.facility === 'gym' ?
: booking.facility === 'hall' ?
:
}
{booking.facilityName}
{booking.date} • {booking.time}
))}
Quadro de Avisos
{issues.slice(0, 3).map((issue) => (
{issue.date}
{issue.title}
{issue.location}
))}
);
const BookingView = ({ facilityType, title, icon: Icon, description, priceInfo, color }) => {
const facilityBookings = bookings.filter(b => b.facility === facilityType);
return (
{title}
{description}
Horário: 08:00 - 22:00
{priceInfo}
Agenda de Reservas
{facilityBookings.length === 0 ? (
Sem reservas agendadas para este espaço.
) : (
{facilityBookings.map(booking => (
{booking.date}
{booking.time}
{booking.resident}
{booking.cost > 0 && (
Custo:
{booking.cost}€
)}
))}
)}
);
};
const MapView = () => {
const [activePoint, setActivePoint] = useState(null);
const [espacos, setEspacos] = useState([]);
const [route, setRoute] = useState(null);
const [isLocating, setIsLocating] = useState(false);
const IconMap = { Building2, ShoppingCart, Store, HeartPulse, Info, MapPin, Trophy, Dumbbell, PartyPopper, Waves };
useEffect(() => {
const defaultEspacos = {
'bloco-a': { nome: 'Bloco A', tipo: 'Residencial', descricao: '10 andares • 20 Frações', icone: 'Building2', color: 'text-orange-500', bg: 'bg-orange-100 dark:bg-orange-900/40', border: 'border-orange-300 dark:border-orange-700/50', x: 8, y: 15, w: 20, h: 28, canBook: false, latitude: 38.7225, longitude: -9.1398 },
'bloco-b': { nome: 'Bloco B', tipo: 'Residencial', descricao: '8 andares • 16 Frações', icone: 'Building2', color: 'text-orange-500', bg: 'bg-orange-100 dark:bg-orange-900/40', border: 'border-orange-300 dark:border-orange-700/50', x: 8, y: 55, w: 20, h: 28, canBook: false, latitude: 38.7215, longitude: -9.1398 },
'mercado-1': { nome: 'Mini Mercado 1', tipo: 'Comércio', descricao: 'Bens de primeira necessidade', icone: 'ShoppingCart', color: 'text-amber-600', bg: 'bg-amber-100 dark:bg-amber-900/40', border: 'border-amber-300 dark:border-amber-700/50', x: 32, y: 20, w: 12, h: 15, canBook: false, latitude: 38.7228, longitude: -9.1390 },
'mercado-2': { nome: 'Mini Mercado 2', tipo: 'Comércio', descricao: 'Mercearia e cafetaria', icone: 'Store', color: 'text-amber-600', bg: 'bg-amber-100 dark:bg-amber-900/40', border: 'border-amber-300 dark:border-amber-700/50', x: 32, y: 65, w: 12, h: 15, canBook: false, latitude: 38.7212, longitude: -9.1390 },
'medico': { nome: 'Posto Médico', tipo: 'Serviços', descricao: 'Primeiros socorros e saúde', icone: 'HeartPulse', color: 'text-red-600', bg: 'bg-red-100 dark:bg-red-900/40', border: 'border-red-300 dark:border-red-700/50', x: 48, y: 20, w: 12, h: 15, canBook: false, latitude: 38.7228, longitude: -9.1385 },
'reception': { nome: 'Recepção', tipo: 'Serviços', descricao: 'Segurança 24h e Encomendas', icone: 'Info', color: 'text-slate-700 dark:text-slate-300', bg: 'bg-slate-200 dark:bg-slate-700', border: 'border-slate-400 dark:border-slate-500', x: 48, y: 45, w: 8, h: 12, isRound: true, canBook: false, latitude: 38.7220, longitude: -9.1385 },
'pool': { nome: 'Piscina', tipo: 'Lazer', descricao: 'Piscina exterior aquecida', icone: 'MapPin', color: 'text-teal-600', bg: 'bg-teal-100 dark:bg-teal-900/40', border: 'border-teal-300 dark:border-teal-700/50', x: 48, y: 65, w: 14, h: 18, canBook: false, latitude: 38.7212, longitude: -9.1385 },
'park': { nome: 'Parque de Jogos', tipo: 'Lazer', descricao: 'Campo Polidesportivo', icone: 'Trophy', color: 'text-green-600', bg: 'bg-green-100 dark:bg-green-900/40', border: 'border-green-300 dark:border-green-700/50', x: 65, y: 15, w: 18, h: 25, canBook: true, bookId: 'park', latitude: 38.7225, longitude: -9.1375 },
'gym': { nome: 'Ginásio', tipo: 'Lazer', descricao: 'Equipamento Cardio e Força', icone: 'Dumbbell', color: 'text-blue-600', bg: 'bg-blue-100 dark:bg-blue-900/40', border: 'border-blue-300 dark:border-blue-700/50', x: 65, y: 48, w: 14, h: 18, canBook: true, bookId: 'gym', latitude: 38.7218, longitude: -9.1375 },
'hall': { nome: 'Salão Festas', type: 'Lazer', descricao: 'Capacidade 50 px', icone: 'PartyPopper', color: 'text-purple-600', bg: 'bg-purple-100 dark:bg-purple-900/40', border: 'border-purple-300 dark:border-purple-700/50', x: 65, y: 72, w: 14, h: 18, canBook: true, bookId: 'hall', latitude: 38.7210, longitude: -9.1375 },
'deck': { nome: 'Deque do Rio', tipo: 'Lazer', descricao: 'Zona de relaxamento à beira rio', icone: 'Waves', color: 'text-cyan-700 dark:text-cyan-300', bg: 'bg-cyan-100 dark:bg-cyan-900/40', border: 'border-cyan-300 dark:border-cyan-700/50', x: 85, y: 40, w: 8, h: 30, canBook: false, latitude: 38.7220, longitude: -9.1360 },
};
const espacosRef = ref(db, 'espacos');
const unsub = onValue(espacosRef, (snapshot) => {
if (snapshot.exists()) {
const data = snapshot.val();
const loadedEspacos = Object.keys(data).map(key => ({ id: key, ...data[key] }));
setEspacos(loadedEspacos);
} else {
// Seed inicial da base de dados se estiver vazia
set(ref(db, 'espacos'), defaultEspacos).catch(console.error);
}
});
return () => unsub();
}, []);
const getDistance = (lat1, lon1, lat2, lon2) => {
const R = 6371; // Raio da Terra em km
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c; // em km
};
const handleRoute = (espaco) => {
setIsLocating(true);
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
const userLat = pos.coords.latitude;
const userLng = pos.coords.longitude;
const distKm = getDistance(userLat, userLng, espaco.latitude, espaco.longitude);
// Mapeamento da localização GPS do utilizador para as coordenadas visuais (x,y) do mapa
const minLat = 38.7205; const maxLat = 38.7230;
const minLng = -9.1405; const maxLng = -9.1350;
let userX = ((userLng - minLng) / (maxLng - minLng)) * 100;
let userY = ((maxLat - userLat) / (maxLat - minLat)) * 100;
// Se estiver fora do condomínio (> 5km), coloca o utilizador na entrada principal
if (distKm > 5) {
userX = 48; // Receção / Portaria
userY = 95; // Entrada
} else {
userX = Math.max(5, Math.min(95, userX));
userY = Math.max(5, Math.min(95, userY));
}
setRoute({
active: true,
targetId: espaco.id,
distance: distKm * 1000,
walkTime: Math.max(1, Math.ceil((distKm / 5) * 60)), // 5 km/h a pé
driveTime: Math.max(1, Math.ceil((distKm / 30) * 60)), // 30 km/h de carro
userX,
userY
});
setIsLocating(false);
},
(error) => {
console.error("Erro de geolocalização:", error);
alert("Não foi possível obter a sua localização. Verifique as permissões do browser.");
setIsLocating(false);
}
);
} else {
alert("Geolocalização não é suportada por este browser.");
setIsLocating(false);
}
};
return (
Navegação Inteligente
Explore e encontre rotas no condomínio
Residencial
Comércio
Lazer
Serviços
{/* Área do Mapa */}
{/* Visual River */}
VIA CENTRAL
{/* SVG Route Overlay */}
{route && route.targetId && (
)}
{espacos.map(loc => {
const IconComp = IconMap[loc.icone] || MapPin;
return (
setActivePoint(loc.id)}
onMouseEnter={() => setActivePoint(loc.id)}
className={`absolute flex flex-col items-center justify-center cursor-pointer transition-all duration-300 border-2 ${loc.bg} ${loc.border} ${activePoint === loc.id ? 'scale-110 shadow-lg z-20 ring-4 ring-blue-400/30' : 'hover:scale-105 shadow-sm z-10'} ${loc.isRound ? 'rounded-full' : 'rounded-lg'}`}
style={{ left: `${loc.x}%`, top: `${loc.y}%`, width: `${loc.w}%`, height: `${loc.h}%` }}
>
{!loc.isRound && (
{loc.nome}
)}
);
})}
{/* Tabela lateral / Legenda */}
Detalhes e Navegação
Selecione um ponto no mapa para ver rotas.
{espacos.map(loc => {
const IconComp = IconMap[loc.icone] || MapPin;
return (
setActivePoint(loc.id)}
className={`p-3 rounded-xl cursor-pointer transition-all border ${activePoint === loc.id ? 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800 shadow-md transform scale-[1.02]' : 'bg-white border-slate-100 hover:border-slate-300 dark:bg-dark-card dark:border-dark-border hover:bg-slate-50 dark:hover:bg-dark-bg'}`}
>
{loc.nome}
{loc.tipo}
{loc.descricao}
{activePoint === loc.id && (
{loc.canBook && (
)}
{route && route.targetId === loc.id && (
Detalhes da Rota
Distância:
{route.distance > 1000 ? (route.distance/1000).toFixed(1) + ' km' : Math.round(route.distance) + ' m'}
A pé:
{route.walkTime} min
De carro:
{route.driveTime} min
)}
)}
);
})}
);
};
const MaintenanceView = () => (
Manutenção e Ocorrências
Gestão de pedidos e reparações
{userRole === 'admin' ? (
) : (
)}
{issues.length === 0 ? (
Sem ocorrências registadas.
) : (
{issues.map(issue => (
{issue.date}
{issue.title}
{issue.location}
Prioridade {issue.priority}
{userRole === 'admin' && issue.status !== 'Resolvido' && (
)}
))}
)}
);
const ProfileView = ({ theme, setTheme }) => {
const [activeSection, setActiveSection] = useState('personal');
const isMorador = userRole !== 'admin';
const [formData, setFormData] = useState({
name: 'A carregar...',
role: '...',
email: '',
contact: '',
address: ''
});
useEffect(() => {
if (isMorador) {
const currentUserData = residents.find(r => r.id === currentUserId) || {};
setFormData({
name: currentUserData.name || currentUserName || '',
role: `Fração ${currentUserData.unit || 'N/A'}`,
email: currentUserData.email || '',
contact: currentUserData.contact || '',
address: 'Morada do Condomínio'
});
} else {
const adminRef = ref(db, 'configuracoes/admin_profile');
const unsub = onValue(adminRef, (snapshot) => {
if (snapshot.exists()) {
setFormData(snapshot.val());
} else {
setFormData({
name: 'Administrador do Condomínio',
role: 'Síndico / Gestor',
email: 'admin@mycondominium.pt',
contact: '+351 912 345 678',
address: 'Rua das Flores, nº 123, Escritório 2B'
});
}
});
return () => unsub();
}
}, [residents, currentUserId, userRole, currentUserName, isMorador]);
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const [passwordData, setPasswordData] = useState({ current: '', new: '', confirm: '' });
const handlePasswordChange = (field, value) => {
setPasswordData(prev => ({ ...prev, [field]: value }));
};
const handleSavePassword = async () => {
if (passwordData.new !== passwordData.confirm) {
showNotification('As novas palavras-passe não coincidem.', 'error');
return;
}
if (passwordData.new.length < 4) {
showNotification('A nova palavra-passe deve ter pelo menos 4 caracteres.', 'error');
return;
}
if (isMorador) {
const currentUserData = residents.find(r => r.id === currentUserId);
const currentPassword = currentUserData.password || currentUserData.contact || '1234';
if (passwordData.current !== currentPassword) {
showNotification('A palavra-passe atual está incorreta.', 'error');
return;
}
try {
await set(ref(db, `condominos/${currentUserData.id}/password`), passwordData.new);
showNotification('Palavra-passe alterada com sucesso!', 'success');
setPasswordData({ current: '', new: '', confirm: '' });
sendSystemNotification('Um utilizador alterou a sua palavra-passe.', 'info', 'admin');
} catch (error) {
console.error("Erro ao alterar palavra-passe:", error);
showNotification('Erro ao alterar a palavra-passe.', 'error');
}
} else {
showNotification('A conta de administrador usa o Firebase Auth para gerir passwords.', 'info');
}
};
const handleSave = async () => {
if (isMorador) {
const currentUserData = residents.find(r => r.id === currentUserId);
if (currentUserData && currentUserData.id) {
try {
await set(ref(db, `condominos/${currentUserData.id}/email`), formData.email);
await set(ref(db, `condominos/${currentUserData.id}/contact`), formData.contact);
showNotification('Dados atualizados com sucesso!', 'success');
sendSystemNotification('Um utilizador atualizou os seus dados pessoais.', 'info', 'admin');
} catch (error) {
console.error("Erro ao guardar os dados:", error);
showNotification('Erro ao guardar os dados.', 'error');
}
}
} else {
try {
await set(ref(db, 'configuracoes/admin_profile'), formData);
showNotification('Alterações guardadas com sucesso!', 'success');
} catch (error) {
console.error("Erro ao guardar perfil admin:", error);
showNotification('Erro ao guardar as alterações.', 'error');
}
}
};
return (
{/* Profile Sidebar */}
{userRole === 'admin' ? 'AD' : 'MO'}
{userRole === 'admin' ? 'Admin Condomínio' : 'Morador'}
{userRole === 'admin' ? 'Administrador Geral' : 'Residente'}
{userRole === 'admin' && (
)}
{/* Profile Content */}
{activeSection === 'personal' && (
Dados Pessoais
handleChange('name', e.target.value)} disabled={isMorador} />
handleChange('role', e.target.value)} disabled={isMorador} />
handleChange('email', e.target.value)} type="email" />
handleChange('contact', e.target.value)} />
handleChange('address', e.target.value)} disabled={isMorador} />
)}
{activeSection === 'security' && (
Segurança
Autenticação de Dois Fatores (2FA)
Recomendamos ativar o 2FA para maior segurança da sua conta.
handlePasswordChange('current', e.target.value)} />
handlePasswordChange('new', e.target.value)} />
handlePasswordChange('confirm', e.target.value)} />
)}
{activeSection === 'permissions' && (
Nível de Acesso
Acesso Total (Admin)
Tem permissões totais para gerir condóminos, finanças e configurações.
Permissões Específicas:
{['Gerir Condóminos (Criar, Editar, Eliminar)', 'Gestão Financeira Completa', 'Moderação de Ocorrências', 'Configuração do Sistema', 'Gestão de Usuários'].map((perm, idx) => (
))}
)}
{activeSection === 'settings' && (
Preferências da Aplicação
Idioma da Aplicação
{[
{ code: 'pt', label: 'Português', flag: '🇵🇹' },
{ code: 'en', label: 'English', flag: '🇬🇧' },
{ code: 'es', label: 'Español', flag: '🇪🇸' },
{ code: 'fr', label: 'Français', flag: '🇫🇷' }
].map(lang => {
const match = document.cookie.match(/googtrans=\/pt\/([a-z]{2})/);
const activeLang = match ? match[1] : 'pt';
const isActive = activeLang === lang.code;
return (
{
if(lang.code === 'pt') {
document.cookie = "googtrans=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
document.cookie = "googtrans=; expires=Thu, 01 Jan 1970 00:00:00 UTC; domain=" + window.location.hostname + "; path=/;";
} else {
document.cookie = `googtrans=/pt/${lang.code}; path=/;`;
document.cookie = `googtrans=/pt/${lang.code}; domain=${window.location.hostname}; path=/;`;
}
window.location.reload();
}}
className={`border-2 p-3 rounded-lg text-center cursor-pointer transition-colors ${isActive ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30' : 'border-slate-200 dark:border-dark-border bg-white dark:bg-dark-card hover:bg-slate-50 dark:hover:bg-dark-surface'}`}
>
{lang.flag}
{lang.label}
);
})}
A tradução é efetuada automaticamente e afetará toda a aplicação após recarregar a página.
Aparência
setTheme('light')}
className={`border-2 ${theme === 'light' ? 'border-blue-500 bg-blue-50' : 'border-slate-200 bg-slate-50'} p-3 rounded-lg text-center cursor-pointer transition-colors`}
>
Claro
setTheme('dark')}
className={`border-2 ${theme === 'dark' ? 'border-blue-500 bg-blue-50' : 'border-slate-200 bg-slate-50'} p-3 rounded-lg text-center cursor-pointer transition-colors`}
>
Escuro
setTheme('system')}
className={`border-2 ${theme === 'system' ? 'border-blue-500 bg-blue-50' : 'border-slate-200 bg-slate-50'} p-3 rounded-lg text-center cursor-pointer transition-colors`}
>
Sistema
)}
);
};
if (!isAuthenticated) {
return ;
}
return (
{/* Mobile Overlay */}
{isSidebarOpen && (
)}
{/* Sidebar */}
{/* Main Content */}
{/* Header */}
{
activeTab === 'dashboard' ? 'Visão Geral' :
activeTab === 'residents' ? 'Condóminos' :
activeTab === 'finance' ? 'Gestão Financeira' :
activeTab === 'billing' ? 'Faturação e Cobranças' :
activeTab === 'maintenance' ? 'Ocorrências e Manutenção' :
activeTab === 'messages' ? 'Mensagens e Fórum' :
activeTab === 'map' ? 'Mapa do Condomínio' :
activeTab === 'all_bookings' ? 'Todas as Reservas' :
activeTab === 'gym' ? 'Ginásio' :
activeTab === 'hall' ? 'Salão de Festas' :
activeTab === 'park' ? 'Parque de Jogos' :
activeTab === 'profile' ? 'O Meu Perfil' : activeTab
}
{/* Notifications */}
{isNotificationsOpen && (
Notificações
{notificationsList.length === 0 ? (
Sem novas notificações
) : (
notificationsList.map(notif => (
{notif.message}
{notif.time}
{!notif.read && (
)}
))
)}
)}
setActiveTab('profile')}
title="Meu Perfil"
>
{userRole === 'admin' ? 'AD' : 'MO'}
{/* Content Body */}
{/* --- DASHBOARD --- */}
{activeTab === 'dashboard' &&
}
{/* --- MAPA --- */}
{activeTab === 'map' &&
}
{/* --- GINÁSIO --- */}
{activeTab === 'gym' && (
)}
{/* --- SALÃO --- */}
{activeTab === 'hall' && (
)}
{/* --- PARQUE --- */}
{activeTab === 'park' && (
)}
{/* --- ALL BOOKINGS --- */}
{activeTab === 'all_bookings' && (
Histórico de Reservas
Lista completa de agendamentos em todos os espaços de lazer
{bookings.map(booking => (
{booking.facility === 'gym' ?
: booking.facility === 'hall' ?
:
}
{booking.facilityName}
{booking.date} • {booking.time}
))}
{bookings.length === 0 && (
Sem reservas
Ainda não existem agendamentos no condomínio.
)}
)}
{/* --- APPROVALS --- */}
{activeTab === 'approvals' && userRole === 'admin' && (
Pagamentos Concluídos
Consulte o histórico de todos os pagamentos concluídos pelos condóminos.
| Morador |
Fatura |
Estado |
Valor |
{faturas.filter(f => f.status === 'Pago').map(fatura => (
|
{fatura.nomeMorador}
Fração: {fatura.fracao}
|
{fatura.categoria}
Venceu a: {fatura.dataVencimento}
|
Pago
|
{Number(fatura.valor).toFixed(2)}€ |
))}
{faturas.filter(f => f.status === 'Pago').length === 0 && (
| Nenhum pagamento concluído encontrado. |
)}
)}
{/* --- RESIDENTS --- */}
{activeTab === 'residents' && (
Gestão de Condóminos
Total: {residents.length} frações registadas
| Fração |
Proprietário |
Contacto |
Estado Quotas |
Acesso |
Em Dívida |
Ações |
{filteredResidents.map((resident) => (
| {resident.unit} |
{resident.name}
{resident.email}
|
{resident.contact} |
|
|
0 ? 'text-red-600 dark:text-red-400' : 'text-slate-600 dark:text-slate-400'}`}>
{Number(resident.pending).toFixed(2)}€
|
|
))}
)}
{/* --- BILLING / COBRANÇAS --- */}
{activeTab === 'billing' && userRole === 'admin' && (
Avisos de Cobrança
Emita faturas ou avise condóminos individualmente
| Fração |
Condómino |
Quotas em Atraso |
Ações |
{residents.map((resident) => (
| {resident.unit} |
{resident.name} |
0 ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-500'}`}>
{resident.pending > 0 ? `${Number(resident.pending).toFixed(2)}€` : 'Regularizado'}
|
|
))}
)}
{/* --- MINHAS CONTAS (Morador) --- */}
{activeTab === 'minhas_contas' && userRole === 'morador' && (
Total Pendente
{faturas.filter(f => f.moradorId === currentUserId && f.status === 'Pendente').reduce((acc, f) => acc + Number(f.valor), 0).toFixed(2)}€
Total Pago
{faturas.filter(f => f.moradorId === currentUserId && f.status === 'Pago').reduce((acc, f) => acc + Number(f.valor), 0).toFixed(2)}€
Minhas Faturas
Consulte as suas despesas e faturas emitidas
| Data Emissão |
Categoria |
Vencimento |
Valor |
Estado |
Ações |
{faturas.filter(f => f.moradorId === currentUserId).length === 0 ? (
|
Nenhuma fatura encontrada.
|
) : (
faturas.filter(f => f.moradorId === currentUserId).map((fatura) => (
| {fatura.dataEmissao} |
{fatura.categoria} |
{fatura.dataVencimento} |
{Number(fatura.valor).toFixed(2)}€ |
|
{fatura.status === 'Pendente' ? (
) : (
Pago
)}
|
))
)}
)}
{/* --- FINANCES --- */}
{/* --- FINANCES --- */}
{activeTab === 'finance' && (
Receitas (Global)
{totalIncome.toFixed(2)}€
Despesas (Global)
{totalExpense.toFixed(2)}€
Balanço Líquido
= 0 ? 'text-blue-600 dark:text-blue-400' : 'text-red-600 dark:text-red-400'}`}>
{balance > 0 ? '+' : ''}{balance.toFixed(2)}€
Diário Financeiro
{finances.length} movimentos
{finances.length === 0 && (
)}
| Data |
Categoria |
Descrição |
Tipo |
Valor |
Recibo |
{finances.map((item) => (
| {item.date} |
{item.category} |
{item.desc} |
|
{item.type === 'income' ? '+' : '-'}{Number(item.amount).toFixed(2)}€
|
|
))}
)}
{/* --- MESSAGES --- */}
{activeTab === 'messages' && (
{/* Contact List */}
Conversas
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'}`}
>
Fórum do Condomínio
Geral
Grupo partilhado
{chatGroups.filter(g => g.members && (Object.values(g.members).map(String).includes(String(currentUserId)) || userRole === 'admin')).map(group => (
setActiveChat({ type: 'group', id: group.id, name: group.name })}
className={`p-3 border-b border-slate-50 dark:border-dark-border/50 cursor-pointer transition-colors ${activeChat.id === group.id ? 'bg-blue-50/50 dark:bg-blue-900/10' : 'hover:bg-slate-50 dark:hover:bg-dark-card'}`}
>
{group.name}
{activeChat.id === group.id && }
Grupo
))}
{residents.filter(r => r.id !== currentUserId).map(res => (
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'}`}
>
{res.name.substring(0, 2).toUpperCase()}
{res.name} {res.unit && `(${res.unit})`}
{activeChat.id === res.id && }
Morador
))}
{/* Chat Area */}
{activeChat.type === 'global' || activeChat.type === 'group' ? : activeChat.name.substring(0, 2).toUpperCase()}
{activeChat.name}
{activeChat.type === 'global' ? 'Todos os moradores' : activeChat.type === 'group' ? 'Grupo Privado' : 'Privado'}
Mensagens
{messages.map((msg) => {
const isMe = msg.senderId === currentUserId;
const timeString = new Date(msg.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
return (
{!isMe && (
{msg.senderName}
)}
{msg.text}
{timeString}
);
})}
)}
{/* --- MAINTENANCE --- */}
{activeTab === 'maintenance' &&
}
{/* --- PROFILE --- */}
{activeTab === 'profile' &&
}
{/* --- Toast Notification --- */}
{notification && (
{notification.type === 'success' ?
:
}
{notification.message}
)}
{/* --- Modals --- */}
{/* Resident Modal */}
{/* Emitir Fatura Modal */}
{/* Finance Modal */}
{/* Issue Modal */}
{/* Booking Modal */}
setIsCreateGroupModalOpen(false)} title="Criar Novo Grupo">
);
}
const root = createRoot(document.getElementById('root'));
root.render(
);