1194 lines
60 KiB
Plaintext
1194 lines
60 KiB
Plaintext
23: Pago
|
|
14: Pendente
|
|
11: Residencial
|
|
8: Lazer
|
|
8: Média
|
|
8: Home
|
|
7: Data
|
|
7: Ginásio
|
|
7: Salão de Festas
|
|
6: Categoria
|
|
6: Alta
|
|
5: Email
|
|
5: Fração
|
|
5: Resolvido
|
|
5: Parque de Jogos
|
|
4: Comércio
|
|
4: Morador
|
|
4: Estado
|
|
4: Nome Completo
|
|
4: Manutenção
|
|
4: Atrasado
|
|
4: Baixa
|
|
4: Confirmado
|
|
4: Utilizador
|
|
4: Moradia T3
|
|
4: Moradia T4
|
|
3: MyCondominium
|
|
3: {title}
|
|
3: {booking.resident}
|
|
3: Serviços
|
|
3: Valor
|
|
3: Ações
|
|
3: Descrição
|
|
3: Tipo
|
|
3: Fórum do Condomínio
|
|
3: Mensagens
|
|
3: Condóminos
|
|
3: Novo
|
|
3: Quotas
|
|
2: {trendValue}
|
|
2: Portal de Gestão
|
|
2: Palavra-passe
|
|
2: {userRole === 'admin' ? (
|
|
2: {booking.facility === 'gym' ?
|
|
2: : booking.facility === 'hall' ?
|
|
2: {booking.facilityName}
|
|
2: {booking.date} • {booking.time}
|
|
2: {issue.date}
|
|
2: {issue.title}
|
|
2: {issue.location}
|
|
2: {loc.nome}
|
|
2: Dados Pessoais
|
|
2: Segurança
|
|
2: Notificações
|
|
2: Geral
|
|
2: Nova Reserva
|
|
2: {fatura.categoria}
|
|
2: {Number(fatura.valor).toFixed(2)}€
|
|
2: {resident.unit}
|
|
2: {resident.name}
|
|
2: Emitir Fatura
|
|
2: Condómino
|
|
2: Criar Grupo
|
|
2: Confirmar
|
|
2: Nome do proprietário
|
|
2: 0.00
|
|
2: Detalhes do movimento
|
|
2: Nome do residente
|
|
2: Telefone
|
|
2: Dashboard
|
|
2: Finanças
|
|
2: Faturação
|
|
2: Pagamentos
|
|
2: Minhas Contas
|
|
2: Mapa
|
|
2: Parque Jogos
|
|
2: Telemóvel
|
|
2: Valor (€)
|
|
2: Data Vencimento
|
|
2: Título do Problema
|
|
2: Localização
|
|
2: Prioridade
|
|
2: Espaço
|
|
2: Horário
|
|
2: Nome do Grupo
|
|
2: Ana Silva
|
|
2: Carlos Santos
|
|
2: Sofia Costa
|
|
2: Em Progresso
|
|
2: Receita
|
|
2: Em Validação
|
|
2: Despesa
|
|
2: Administração
|
|
2: Náutica
|
|
2: Perfil
|
|
2: AD
|
|
2: MO
|
|
2: Luz
|
|
2: Aluguer
|
|
2: Outros
|
|
1: tailwind.config = {
|
|
darkMode: 'class',
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
dark: {
|
|
bg: '#0f172a',
|
|
surface: '#1e293b',
|
|
border: '#334155',
|
|
card: '#1e293b',
|
|
mute: '#94a3b8'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
1: function googleTranslateElementInit() {
|
|
new google.translate.TranslateElement({pageLanguage: 'pt', includedLanguages: 'pt,en,es,fr', autoDisplay: false}, 'google_translate_element');
|
|
}
|
|
1: /* Esconder a barra e o widget do Google Translate nativos */
|
|
.skiptranslate iframe, .goog-te-banner-frame { display: none !important; }
|
|
body { top: 0px !important; }
|
|
#google_translate_element { display: none !important; }
|
|
.goog-tooltip { display: none !important; }
|
|
.goog-tooltip:hover { display: none !important; }
|
|
.goog-text-highlight { background-color: transparent !important; border: none !important; box-shadow: none !important; }
|
|
1: {
|
|
"imports": {
|
|
"react": "https://esm.sh/react@18.2.0",
|
|
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
|
|
"lucide-react": "https://esm.sh/lucide-react@0.292.0"
|
|
}
|
|
}
|
|
1: if (typeof Node === 'function' && Node.prototype) {
|
|
const originalRemoveChild = Node.prototype.removeChild;
|
|
Node.prototype.removeChild = function(child) {
|
|
if (child.parentNode !== this) {
|
|
if (console) console.warn('Cannot remove a child from a different parent', child, this);
|
|
return child;
|
|
}
|
|
return originalRemoveChild.apply(this, arguments);
|
|
};
|
|
const originalInsertBefore = Node.prototype.insertBefore;
|
|
Node.prototype.insertBefore = function(newNode, referenceNode) {
|
|
if (referenceNode && referenceNode.parentNode !== this) {
|
|
if (console) console.warn('Cannot insert before a reference node from a different parent', referenceNode, this);
|
|
return newNode;
|
|
}
|
|
return originalInsertBefore.apply(this, arguments);
|
|
};
|
|
}
|
|
1: @keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
}
|
|
|
|
to {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
@keyframes slideUp {
|
|
from {
|
|
transform: translateY(20px);
|
|
opacity: 0;
|
|
}
|
|
|
|
to {
|
|
transform: translateY(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.animate-fade-in {
|
|
animation: fadeIn 0.5s ease-out forwards;
|
|
}
|
|
|
|
.animate-slide-up {
|
|
animation: slideUp 0.4s ease-out forwards;
|
|
}
|
|
|
|
::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: #cbd5e1;
|
|
border-radius: 3px;
|
|
}
|
|
1: 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, Home, Anchor, Fuel
|
|
} 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 (
|
|
1: Algo correu mal (Erro na Aplicação)
|
|
1: {this.state.error && this.state.error.toString()}
|
|
1: {this.state.errorInfo && this.state.errorInfo.componentStack}
|
|
1: window.location.reload()} style={{ marginTop: '20px', padding: '10px 20px', backgroundColor: '#dc2626', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>Recarregar Página
|
|
1: );
|
|
}
|
|
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
|
|
1: = 0; i--) {
|
|
let charCode = docStr.charCodeAt(i);
|
|
let val = 0;
|
|
if (charCode >= 48 && charCode
|
|
1: = 65 && charCode
|
|
1: = 36) val -= 36;
|
|
}
|
|
sum += val;
|
|
isSecond = !isSecond;
|
|
}
|
|
return (sum % 36) === 0;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const Modal = ({ isOpen, onClose, title, children }) => {
|
|
if (!isOpen) return null;
|
|
return (
|
|
1: {children}
|
|
1: );
|
|
};
|
|
|
|
const InputGroup = ({ label, name, type = 'text', value, onChange, placeholder, required = false, options = null, disabled = false }) => (
|
|
1: {label} {required &&
|
|
1: {options ? (
|
|
1: {options.map(opt => (
|
|
1: {opt.label}
|
|
1: {!value && placeholder && (
|
|
1: {placeholder}
|
|
1: );
|
|
|
|
const SidebarItem = ({ icon: Icon, label, active, onClick }) => (
|
|
1: {label}
|
|
1: );
|
|
|
|
const Card = ({ title, value, icon: Icon, trend, trendValue, color, subtitle }) => (
|
|
1: {value}
|
|
1: {trend === 'up' ? (
|
|
1: ) : trend === 'down' ? (
|
|
1: {subtitle || 'vs. mês passado'}
|
|
1: );
|
|
|
|
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 (
|
|
1: {status}
|
|
1: );
|
|
};
|
|
|
|
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 (
|
|
1: setEmail(e.target.value)}
|
|
className="peer w-full px-4 py-3 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 transition-colors"
|
|
autoFocus
|
|
required
|
|
/>
|
|
{!email && (
|
|
1: Endereço de email
|
|
1: setPassword(e.target.value)}
|
|
className="peer w-full px-4 py-3 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 transition-colors"
|
|
required
|
|
/>
|
|
{!password && (
|
|
1: Senha de acesso
|
|
1: {error && (
|
|
1: {error}
|
|
1: {isLoading ? (
|
|
1: A entrar...
|
|
1: ) : (
|
|
'Entrar'
|
|
)}
|
|
1: );
|
|
};
|
|
|
|
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 = () => {
|
|
openConfirm('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 [adminProfile, setAdminProfile] = 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);
|
|
const unsubAdmin = onValue(ref(db, 'configuracoes/admin_profile'), (snapshot) => {
|
|
if (snapshot.exists()) setAdminProfile(snapshot.val());
|
|
});
|
|
|
|
return () => {
|
|
unsubResidents();
|
|
unsubFinances();
|
|
unsubIssues();
|
|
unsubBookings();
|
|
unsubInvoices();
|
|
unsubFaturas();
|
|
unsubGroups();
|
|
unsubAdmin();
|
|
};
|
|
}, []);
|
|
|
|
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 [confirmDialog, setConfirmDialog] = useState({ isOpen: false, message: '', onConfirm: null });
|
|
const openConfirm = (message, onConfirm) => setConfirmDialog({ isOpen: true, message, onConfirm });
|
|
|
|
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) => {
|
|
openConfirm('Tem a certeza que deseja eliminar este condómino?', async () => {
|
|
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
|
|
1: {
|
|
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
|
|
1: {
|
|
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
|
|
1: = 0 ? 'up' : 'down'} trendValue="Atual" color="bg-blue-500" />
|
|
) : (
|
|
1: Próximas Reservas
|
|
1: setActiveTab('all_bookings')}>Ver todas as Reservas
|
|
1: {bookings.slice(0, 4).map(booking => (
|
|
1: Quadro de Avisos
|
|
1: setActiveTab('maintenance')}>{userRole === 'admin' ? 'Gerir' : 'Ver Ocorrências'}
|
|
1: {issues.slice(0, 3).map((issue) => (
|
|
1: );
|
|
|
|
const BookingView = ({ facilityType, title, icon: Icon, description, priceInfo, color }) => {
|
|
const facilityBookings = bookings.filter(b => b.facility === facilityType);
|
|
|
|
return (
|
|
1: {description}
|
|
1: Horário: 08:00 - 22:00
|
|
1: {priceInfo}
|
|
1: handleOpenModal('booking', null, facilityType)}
|
|
className="bg-white text-slate-900 px-6 py-3 rounded-lg font-bold hover:bg-slate-50 transition-colors shadow-lg flex items-center gap-2"
|
|
>
|
|
1: Reservar Agora
|
|
1: Agenda de Reservas
|
|
1: {facilityBookings.length === 0 ? (
|
|
1: Sem reservas agendadas para este espaço.
|
|
1: {facilityBookings.map(booking => (
|
|
1: {booking.date}
|
|
1: {booking.time}
|
|
1: {booking.cost > 0 && (
|
|
1: Custo:
|
|
1: {booking.cost}€
|
|
1: );
|
|
};
|
|
|
|
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, Home, Anchor, Fuel };
|
|
|
|
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', cx: 15, cy: 15, w: 20, h: 28, canBook: false, latitude: 38.7225, longitude: -9.1398 },
|
|
'moradia-1': { nome: 'Moradia 1', tipo: 'Residencial', descricao: 'Moradia T3', icone: 'Home', color: 'text-teal-600', bg: 'bg-teal-100 dark:bg-teal-900/40', border: 'border-teal-300 dark:border-teal-700/50', cx: 12, cy: 40, w: 12, h: 14, canBook: false, latitude: 38.7220, longitude: -9.1396 },
|
|
'moradia-2': { nome: 'Moradia 2', tipo: 'Residencial', descricao: 'Moradia T4', icone: 'Home', color: 'text-teal-600', bg: 'bg-teal-100 dark:bg-teal-900/40', border: 'border-teal-300 dark:border-teal-700/50', cx: 35, cy: 10, w: 12, h: 14, canBook: false, latitude: 38.7228, longitude: -9.1392 },
|
|
'moradia-3': { nome: 'Moradia 3', tipo: 'Residencial', descricao: 'Moradia T3', icone: 'Home', color: 'text-teal-600', bg: 'bg-teal-100 dark:bg-teal-900/40', border: 'border-teal-300 dark:border-teal-700/50', cx: 45, cy: 35, w: 12, h: 14, canBook: false, latitude: 38.7222, longitude: -9.1388 },
|
|
'moradia-4': { nome: 'Moradia 4', tipo: 'Residencial', descricao: 'Moradia T4', icone: 'Home', color: 'text-teal-600', bg: 'bg-teal-100 dark:bg-teal-900/40', border: 'border-teal-300 dark:border-teal-700/50', cx: 55, cy: 80, w: 12, h: 14, canBook: false, latitude: 38.7210, longitude: -9.1382 },
|
|
'moradia-5': { nome: 'Moradia 5', tipo: 'Residencial', descricao: 'Moradia T3', icone: 'Home', color: 'text-teal-600', bg: 'bg-teal-100 dark:bg-teal-900/40', border: 'border-teal-300 dark:border-teal-700/50', cx: 25, cy: 60, w: 12, h: 14, canBook: false, latitude: 38.7214, longitude: -9.1394 },
|
|
'moradia-6': { nome: 'Moradia 6', tipo: 'Residencial', descricao: 'Moradia T4', icone: 'Home', color: 'text-teal-600', bg: 'bg-teal-100 dark:bg-teal-900/40', border: 'border-teal-300 dark:border-teal-700/50', cx: 65, cy: 30, w: 12, h: 14, canBook: false, latitude: 38.7224, longitude: -9.1378 },
|
|
'moradia-7': { nome: 'Moradia 7', tipo: 'Residencial', descricao: 'Moradia T3', icone: 'Home', color: 'text-teal-600', bg: 'bg-teal-100 dark:bg-teal-900/40', border: 'border-teal-300 dark:border-teal-700/50', cx: 8, cy: 80, w: 12, h: 14, canBook: false, latitude: 38.7216, longitude: -9.1399 },
|
|
'moradia-8': { nome: 'Moradia 8', tipo: 'Residencial', descricao: 'Moradia T4', icone: 'Home', color: 'text-teal-600', bg: 'bg-teal-100 dark:bg-teal-900/40', border: 'border-teal-300 dark:border-teal-700/50', cx: 20, cy: 90, w: 12, h: 14, canBook: false, latitude: 38.7214, longitude: -9.1396 },
|
|
'mercado-1': { nome: 'Supermercado', tipo: 'Comércio', descricao: 'Bens 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', cx: 40, cy: 50, w: 14, h: 16, canBook: false, latitude: 38.7218, longitude: -9.1389 },
|
|
'mercado-2': { nome: 'Cafetaria', 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', cx: 75, cy: 65, w: 12, h: 14, canBook: false, latitude: 38.7213, longitude: -9.1372 },
|
|
'medico': { nome: 'Clínica', tipo: 'Serviços', descricao: 'Saúde e Bem-estar', icone: 'HeartPulse', color: 'text-red-600', bg: 'bg-red-100 dark:bg-red-900/40', border: 'border-red-300 dark:border-red-700/50', cx: 35, cy: 90, w: 14, h: 16, canBook: false, latitude: 38.7208, longitude: -9.1392 },
|
|
'reception': { nome: 'Portaria Principal', tipo: 'Serviços', descricao: 'Segurança 24h', 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', cx: 8, cy: 50, w: 10, h: 10, isRound: true, canBook: false, latitude: 38.7219, longitude: -9.1400 },
|
|
'pool': { nome: 'Piscina', tipo: 'Lazer', descricao: 'Piscina exterior', icone: 'MapPin', color: 'text-teal-600', bg: 'bg-teal-100 dark:bg-teal-900/40', border: 'border-teal-300 dark:border-teal-700/50', cx: 60, cy: 55, w: 16, h: 18, canBook: false, latitude: 38.7215, longitude: -9.1380 },
|
|
'park': { nome: 'Parque', tipo: 'Lazer', descricao: 'Parque de jogos', icone: 'Trophy', color: 'text-green-600', bg: 'bg-green-100 dark:bg-green-900/40', border: 'border-green-300 dark:border-green-700/50', cx: 50, cy: 15, w: 18, h: 22, canBook: true, bookId: 'park', latitude: 38.7228, longitude: -9.1385 },
|
|
'gym': { nome: 'Ginásio', tipo: 'Lazer', descricao: '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', cx: 75, cy: 85, w: 14, h: 16, canBook: true, bookId: 'gym', latitude: 38.7209, longitude: -9.1374 },
|
|
'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', cx: 65, cy: 15, w: 14, h: 16, canBook: true, bookId: 'hall', latitude: 38.7205, longitude: -9.1398 },
|
|
'marina': { nome: 'Aluguer Barcos', tipo: 'Náutica', descricao: 'Barcos e motas de água', icone: 'Anchor', 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', cx: 86, cy: 35, w: 12, h: 14, canBook: true, bookId: 'marina', latitude: 38.7222, longitude: -9.1355 },
|
|
'fuel': { nome: 'Bomba Náutica', tipo: 'Náutica', descricao: 'Abastecimento', icone: 'Fuel', color: 'text-red-500', bg: 'bg-red-100 dark:bg-red-900/40', border: 'border-red-300 dark:border-red-700/50', cx: 86, cy: 50, w: 10, h: 10, isRound: true, canBook: false, latitude: 38.7219, longitude: -9.1355 },
|
|
'deck': { nome: 'Deque', tipo: 'Lazer', descricao: 'Lazer', 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', cx: 88, cy: 75, w: 10, h: 25, canBook: false, latitude: 38.7212, longitude: -9.1350 },
|
|
};
|
|
|
|
const espacosRef = ref(db, 'espacos');
|
|
const unsub = onValue(espacosRef, (snapshot) => {
|
|
if (snapshot.exists()) {
|
|
const data = snapshot.val();
|
|
if (!data['moradia-8']) {
|
|
set(ref(db, 'espacos'), defaultEspacos).catch(console.error);
|
|
const loadedEspacos = Object.keys(defaultEspacos).map(key => ({ id: key, ...defaultEspacos[key] }));
|
|
setEspacos(loadedEspacos);
|
|
} else {
|
|
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);
|
|
showNotification("Não foi possível obter a sua localização. Verifique as permissões do browser.", "error");
|
|
setIsLocating(false);
|
|
}
|
|
);
|
|
} else {
|
|
showNotification("Geolocalização não é suportada por este browser.", "error");
|
|
setIsLocating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
1: Navegação Inteligente
|
|
1: Explore e encontre rotas no condomínio
|
|
1: {/* Área do Mapa */}
|
|
1: {/* Visual River */}
|
|
1: RIO TEJO
|
|
1: {/* Ruas e Caminhos em SVG */}
|
|
1: {/* Via Central */}
|
|
1: {/* Caminhos Secundários Orgânicos */}
|
|
1: {/* Top Arch */}
|
|
1: {/* Middle Top Winding */}
|
|
1: {/* Bottom Winding S-shape */}
|
|
1: {/* Ramos de Ligação (Atalhos) */}
|
|
1: {/* Bloco A */}
|
|
1: {/* Moradia 7 */}
|
|
1: {/* Moradia 8 */}
|
|
1: {/* Moradia 1 */}
|
|
1: {/* Moradia 2 */}
|
|
1: {/* Moradia 3 */}
|
|
1: {/* Moradia 4 */}
|
|
1: {/* Moradia 5 */}
|
|
1: {/* Moradia 6 */}
|
|
1: {/* Cafetaria */}
|
|
1: {/* Clinica */}
|
|
1: {/* Piscina */}
|
|
1: {/* Parque */}
|
|
1: {/* Ginasio */}
|
|
1: {/* Salao */}
|
|
1: {/* Barcos */}
|
|
1: {/* Deque */}
|
|
1: {/* Centros das Vias Orgânicas */}
|
|
1: VIA CENTRAL
|
|
1: {/* Árvores Decorativas (Espalhadas) */}
|
|
{[
|
|
{x: 5, y: 5}, {x: 12, y: 8}, {x: 25, y: 5}, {x: 45, y: 8}, {x: 60, y: 5}, {x: 80, y: 8},
|
|
{x: 5, y: 90}, {x: 12, y: 85}, {x: 25, y: 90}, {x: 45, y: 88}, {x: 60, y: 92}, {x: 80, y: 85},
|
|
{x: 25, y: 35}, {x: 45, y: 45}, {x: 80, y: 40},
|
|
{x: 25, y: 65}, {x: 45, y: 60}, {x: 80, y: 60},
|
|
{x: 50, y: 30}, {x: 55, y: 65}, {x: 10, y: 70}
|
|
].map((tree, i) => (
|