2971 lines
225 KiB
HTML
2971 lines
225 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="pt">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta name="theme-color" content="#0f172a">
|
|
<title>MyCondominium</title>
|
|
<link rel="manifest" href="./manifest.json">
|
|
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
darkMode: 'class',
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
dark: {
|
|
bg: '#0f172a',
|
|
surface: '#1e293b',
|
|
border: '#334155',
|
|
card: '#1e293b',
|
|
mute: '#94a3b8'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<!-- Google Translate Script -->
|
|
<script type="text/javascript">
|
|
function googleTranslateElementInit() {
|
|
new google.translate.TranslateElement({pageLanguage: 'pt', includedLanguages: 'pt,en,es,fr', autoDisplay: false}, 'google_translate_element');
|
|
}
|
|
</script>
|
|
<script type="text/javascript" src="https://translate.google.com/translate_a/element.js?cb=googleTranslateElementInit"></script>
|
|
|
|
<style>
|
|
/* 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; }
|
|
</style>
|
|
|
|
<script type="importmap">
|
|
{
|
|
"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"
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
|
|
<style>
|
|
@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;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div id="root"></div>
|
|
|
|
<script type="text/babel" data-type="module">
|
|
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 (
|
|
<div style={{ padding: '20px', backgroundColor: '#fee2e2', color: '#991b1b', fontFamily: 'sans-serif', height: '100vh' }}>
|
|
<h1 style={{ fontSize: '24px', fontWeight: 'bold' }}>Algo correu mal (Erro na Aplicação)</h1>
|
|
<pre style={{ marginTop: '20px', whiteSpace: 'pre-wrap', backgroundColor: '#fef2f2', padding: '15px', border: '1px solid #f87171' }}>
|
|
{this.state.error && this.state.error.toString()}
|
|
<br />
|
|
{this.state.errorInfo && this.state.errorInfo.componentStack}
|
|
</pre>
|
|
<button onClick={() => window.location.reload()} style={{ marginTop: '20px', padding: '10px 20px', backgroundColor: '#dc2626', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>Recarregar Página</button>
|
|
</div>
|
|
);
|
|
}
|
|
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 (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
|
|
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-2xl w-full max-w-lg overflow-hidden animate-slide-up transition-colors">
|
|
<div className="flex justify-between items-center p-6 border-b border-slate-100 dark:border-dark-border bg-slate-50 dark:bg-dark-bg">
|
|
<h3 className="font-bold text-lg text-slate-800 dark:text-white">{title}</h3>
|
|
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
|
|
<X size={24} />
|
|
</button>
|
|
</div>
|
|
<div className="p-6 max-h-[80vh] overflow-y-auto">{children}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const InputGroup = ({ label, name, type = 'text', value, onChange, placeholder, required = false, options = null, disabled = false }) => (
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
|
{label} {required && <span className="text-red-500">*</span>}
|
|
</label>
|
|
{options ? (
|
|
<select
|
|
name={name}
|
|
value={value}
|
|
onChange={onChange}
|
|
disabled={disabled}
|
|
className="w-full px-3 py-2 border border-slate-300 dark:border-dark-border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-dark-card dark:text-white disabled:bg-slate-100 dark:disabled:bg-slate-800 disabled:text-slate-500 dark:disabled:text-slate-400 transition-colors"
|
|
>
|
|
{options.map(opt => (
|
|
<option key={opt.value} value={opt.value} className="dark:bg-dark-bg">{opt.label}</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
type={type}
|
|
name={name}
|
|
value={value}
|
|
onChange={onChange}
|
|
placeholder={placeholder}
|
|
required={required}
|
|
disabled={disabled}
|
|
className="w-full px-3 py-2 border border-slate-300 dark:border-dark-border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-dark-card dark:text-white dark:placeholder-slate-500 disabled:bg-slate-100 dark:disabled:bg-slate-800 disabled:text-slate-500 dark:disabled:text-slate-400 transition-colors"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const SidebarItem = ({ icon: Icon, label, active, onClick }) => (
|
|
<button
|
|
onClick={onClick}
|
|
className={`flex items-center w-full gap-3 px-4 py-3 text-sm font-medium transition-all rounded-lg ${active
|
|
? 'bg-blue-600 text-white shadow-md translate-x-1'
|
|
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-dark-card hover:text-blue-600 dark:hover:text-blue-400'
|
|
}`}
|
|
>
|
|
<Icon size={20} />
|
|
<span>{label}</span>
|
|
</button>
|
|
);
|
|
|
|
const Card = ({ title, value, icon: Icon, trend, trendValue, color, subtitle }) => (
|
|
<div className="bg-white dark:bg-dark-surface p-6 rounded-xl shadow-sm border border-slate-100 dark:border-dark-border hover:shadow-md transition-all">
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-500 dark:text-dark-mute">{title}</p>
|
|
<h3 className="text-2xl font-bold text-slate-800 dark:text-white mt-2">{value}</h3>
|
|
</div>
|
|
<div className={`p-3 rounded-lg ${color}`}>
|
|
<Icon size={24} className="text-white" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 flex items-center text-sm justify-between">
|
|
<div className="flex items-center">
|
|
{trend === 'up' ? (
|
|
<span className="text-green-500 flex items-center font-medium bg-green-50 dark:bg-green-900/20 px-2 py-0.5 rounded-full">
|
|
<TrendingUp size={14} className="mr-1" /> {trendValue}
|
|
</span>
|
|
) : trend === 'down' ? (
|
|
<span className="text-red-500 flex items-center font-medium bg-red-50 dark:bg-red-900/20 px-2 py-0.5 rounded-full">
|
|
<TrendingDown size={14} className="mr-1" /> {trendValue}
|
|
</span>
|
|
) : (
|
|
<span className="text-slate-400 dark:text-slate-500 font-medium bg-slate-50 dark:bg-dark-card px-2 py-0.5 rounded-full">
|
|
—
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-slate-400 dark:text-dark-mute text-xs">{subtitle || 'vs. mês passado'}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
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 (
|
|
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold border ${styles[status] || 'bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700'}`}>
|
|
{status}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<div className="min-h-screen flex items-center justify-center bg-slate-100 dark:bg-dark-bg p-4 transition-colors duration-300 font-sans">
|
|
<div className="bg-white dark:bg-dark-surface p-8 rounded-xl shadow-2xl w-full max-w-md animate-fade-in border border-slate-100 dark:border-dark-border">
|
|
<div className="text-center mb-8">
|
|
<div className="inline-flex p-3 bg-blue-100 dark:bg-blue-900/30 rounded-full mb-4 text-blue-600 dark:text-blue-400">
|
|
<Building2 size={32} />
|
|
</div>
|
|
<h1 className="text-2xl font-bold text-slate-800 dark:text-white">MyCondominium</h1>
|
|
<p className="text-slate-500 dark:text-gray-400 mt-2">Portal de Gestão</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Email</label>
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
className="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"
|
|
placeholder="Endereço de email"
|
|
autoFocus
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Palavra-passe</label>
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="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"
|
|
placeholder="Senha de acesso"
|
|
required
|
|
/>
|
|
</div>
|
|
{error && (
|
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded-lg flex items-center gap-2 animate-fade-in">
|
|
<AlertCircle size={16} />
|
|
{error}
|
|
</div>
|
|
)}
|
|
<div className="space-y-4">
|
|
<button type="submit" disabled={isLoading} className={`w-full ${isLoading ? 'bg-blue-400 dark:bg-blue-600/50 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700 shadow-lg shadow-blue-500/30'} text-white font-bold py-3 rounded-lg transition-colors flex justify-center items-center gap-2`}>
|
|
{isLoading ? (
|
|
<>
|
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
|
A entrar...
|
|
</>
|
|
) : (
|
|
'Entrar'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 <= 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 = () => (
|
|
<div className="space-y-6 animate-fade-in">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
{userRole === 'admin' ? (
|
|
<Card title="Saldo Disponível" value={`${balance.toFixed(2)}€`} icon={Wallet} trend={balance >= 0 ? 'up' : 'down'} trendValue="Atual" color="bg-blue-500" />
|
|
) : (
|
|
<Card title="As Minhas Quotas" value="Em Dia" icon={CheckCircle} trend="up" trendValue="Pago" color="bg-green-500" subtitle="Sem valores pendentes" />
|
|
)}
|
|
<Card title="Reservas (Mês)" value={bookings.length} icon={Calendar} trend="up" trendValue="+2" color="bg-purple-500" subtitle="Total agendado" />
|
|
<Card title="Manutenções Ativas" value={activeIssuesCount} icon={Wrench} trend="up" trendValue="Novas" color="bg-orange-500" subtitle="Em resolução" />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-sm border border-slate-100 dark:border-dark-border flex flex-col h-full transition-colors">
|
|
<div className="p-6 border-b border-slate-100 dark:border-dark-border flex justify-between items-center">
|
|
<h3 className="font-bold text-lg text-slate-800 dark:text-white">Próximas Reservas</h3>
|
|
<button className="text-blue-600 dark:text-blue-400 text-sm font-medium" onClick={() => setActiveTab('all_bookings')}>Ver todas as Reservas</button>
|
|
</div>
|
|
<div className="p-4 space-y-3">
|
|
{bookings.slice(0, 4).map(booking => (
|
|
<div key={booking.id} className="flex items-center justify-between p-3 border border-slate-100 dark:border-dark-border rounded-lg hover:bg-slate-50 dark:hover:bg-dark-card transition-colors">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-2 rounded-lg ${booking.facility === 'gym' ? 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' : booking.facility === 'hall' ? 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400' : 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400'}`}>
|
|
{booking.facility === 'gym' ? <Dumbbell size={18} /> : booking.facility === 'hall' ? <PartyPopper size={18} /> : <Trophy size={18} />}
|
|
</div>
|
|
<div>
|
|
<p className="font-bold text-sm text-slate-800 dark:text-slate-200">{booking.facilityName}</p>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">{booking.date} • {booking.time}</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">{booking.resident}</p>
|
|
<Badge status={booking.status} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-sm border border-slate-100 dark:border-dark-border flex flex-col h-full transition-colors">
|
|
<div className="p-6 border-b border-slate-100 dark:border-dark-border flex justify-between items-center">
|
|
<h3 className="font-bold text-lg text-slate-800 dark:text-white">Quadro de Avisos</h3>
|
|
<button className="text-sm text-blue-600 dark:text-blue-400" onClick={() => setActiveTab('maintenance')}>{userRole === 'admin' ? 'Gerir' : 'Ver Ocorrências'}</button>
|
|
</div>
|
|
<div className="p-4 space-y-3 overflow-y-auto max-h-[350px]">
|
|
{issues.slice(0, 3).map((issue) => (
|
|
<div key={issue.id} className="border border-slate-100 dark:border-dark-border rounded-lg p-4 bg-slate-50 dark:bg-dark-card transition-colors">
|
|
<div className="flex justify-between mb-2">
|
|
<Badge status={issue.priority} />
|
|
<span className="text-xs text-slate-400 dark:text-slate-500">{issue.date}</span>
|
|
</div>
|
|
<h4 className="font-semibold text-slate-800 dark:text-slate-200">{issue.title}</h4>
|
|
<p className="text-xs text-slate-500 dark:text-dark-mute mt-1">{issue.location}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const BookingView = ({ facilityType, title, icon: Icon, description, priceInfo, color }) => {
|
|
const facilityBookings = bookings.filter(b => b.facility === facilityType);
|
|
|
|
return (
|
|
<div className="animate-fade-in space-y-6">
|
|
<div className={`bg-white dark:bg-dark-surface p-6 rounded-xl shadow-sm border border-slate-100 dark:border-dark-border flex flex-col md:flex-row gap-6 items-center ${color}`}>
|
|
<div className="bg-white/20 p-6 rounded-2xl backdrop-blur-sm text-white">
|
|
<Icon size={48} />
|
|
</div>
|
|
<div className="flex-1 text-white">
|
|
<h2 className="text-2xl font-bold">{title}</h2>
|
|
<p className="opacity-90 mt-1">{description}</p>
|
|
<div className="mt-4 flex gap-4 text-sm font-medium bg-black/10 w-fit px-4 py-2 rounded-lg">
|
|
<span className="flex items-center gap-2"><Clock size={16} /> Horário: 08:00 - 22:00</span>
|
|
<span className="flex items-center gap-2"><Wallet size={16} /> {priceInfo}</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => 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"
|
|
>
|
|
<Plus size={20} /> Reservar Agora
|
|
</button>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-sm border border-slate-100 dark:border-dark-border p-6 transition-colors">
|
|
<h3 className="font-bold text-lg text-slate-800 dark:text-white mb-4">Agenda de Reservas</h3>
|
|
{facilityBookings.length === 0 ? (
|
|
<div className="text-center py-12 text-slate-400 dark:text-dark-mute border-2 border-dashed border-slate-200 dark:border-dark-border rounded-xl">
|
|
<Calendar size={48} className="mx-auto mb-2 opacity-20" />
|
|
<p>Sem reservas agendadas para este espaço.</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{facilityBookings.map(booking => (
|
|
<div key={booking.id} className="border border-slate-200 dark:border-dark-border rounded-lg p-4 hover:shadow-md transition-all bg-white dark:bg-dark-card relative overflow-hidden group">
|
|
<div className={`absolute top-0 left-0 w-1 h-full ${facilityType === 'gym' ? 'bg-blue-500' : facilityType === 'hall' ? 'bg-purple-500' : 'bg-green-500'}`}></div>
|
|
<div className="flex justify-between items-start mb-2 pl-2">
|
|
<span className="font-mono text-sm font-bold text-slate-500 dark:text-slate-400">{booking.date}</span>
|
|
<Badge status={booking.status} />
|
|
</div>
|
|
<h4 className="font-bold text-slate-800 dark:text-white text-lg pl-2">{booking.time}</h4>
|
|
<p className="text-sm text-slate-600 dark:text-dark-mute pl-2 mt-1 flex items-center gap-2">
|
|
<Users size={14} /> {booking.resident}
|
|
</p>
|
|
{booking.cost > 0 && (
|
|
<div className="mt-3 pl-2 pt-3 border-t border-slate-100 dark:border-dark-border text-right">
|
|
<span className="text-xs font-medium text-slate-400 uppercase">Custo:</span>
|
|
<span className="ml-2 font-bold text-slate-800 dark:text-white">{booking.cost}€</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-sm border border-slate-100 dark:border-dark-border overflow-hidden animate-fade-in flex flex-col h-full transition-colors">
|
|
<div className="p-6 border-b border-slate-100 dark:border-dark-border flex justify-between items-center">
|
|
<div>
|
|
<h3 className="font-bold text-lg text-slate-800 dark:text-white">Navegação Inteligente</h3>
|
|
<p className="text-sm text-slate-500 dark:text-dark-mute">Explore e encontre rotas no condomínio</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-3">
|
|
<span className="flex items-center gap-1 text-xs font-medium bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 px-2 py-1 rounded">Residencial</span>
|
|
<span className="flex items-center gap-1 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 px-2 py-1 rounded">Comércio</span>
|
|
<span className="flex items-center gap-1 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 px-2 py-1 rounded">Lazer</span>
|
|
<span className="flex items-center gap-1 text-xs font-medium bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 px-2 py-1 rounded">Serviços</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col lg:flex-row flex-1 min-h-[700px]">
|
|
{/* Área do Mapa */}
|
|
<div className="flex-1 p-6 bg-slate-50 dark:bg-dark-bg flex items-center justify-center relative overflow-hidden">
|
|
<div className="relative w-full h-full max-h-[800px] min-h-[600px] bg-[#eef8f2] dark:bg-[#1a2e23] border-4 border-slate-300 dark:border-dark-border rounded-2xl shadow-2xl p-4 transition-all duration-500 overflow-hidden">
|
|
<div className="absolute inset-0 opacity-10 bg-[linear-gradient(0deg,transparent_24%,#000_25%,#000_26%,transparent_27%,transparent_74%,#000_75%,#000_76%,transparent_77%,transparent),linear-gradient(90deg,transparent_24%,#000_25%,#000_26%,transparent_27%,transparent_74%,#000_75%,#000_76%,transparent_77%,transparent)] bg-[length:40px_40px]"></div>
|
|
|
|
{/* Visual River */}
|
|
<div className="absolute right-0 top-0 bottom-0 w-[12%] bg-blue-400/30 dark:bg-blue-600/30 border-l-8 border-blue-300/50 dark:border-blue-700/50 flex items-center justify-center overflow-hidden pointer-events-none">
|
|
<div className="text-blue-600/40 dark:text-blue-300/30 font-black text-4xl rotate-90 whitespace-nowrap tracking-[1em]">RIO TEJO</div>
|
|
</div>
|
|
|
|
{/* Ruas e Caminhos em SVG */}
|
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="absolute inset-0 w-full h-full pointer-events-none z-0" style={{ overflow: 'visible' }}>
|
|
{/* Via Central */}
|
|
<rect x="0" y="46" width="88" height="8" fill="#cbd5e1" className="dark:fill-slate-700" />
|
|
<line x1="0" y1="50" x2="88" y2="50" stroke="#f8fafc" strokeWidth="0.5" strokeDasharray="2,2" className="dark:stroke-slate-400" />
|
|
|
|
{/* Caminhos Secundários Orgânicos */}
|
|
<g stroke="#cbd5e1" strokeWidth="3" className="dark:stroke-slate-700" strokeLinecap="round" strokeLinejoin="round" fill="none">
|
|
{/* Top Arch */}
|
|
<path d="M 25 50 C 25 15, 40 5, 55 5 C 75 5, 80 20, 80 30 C 80 40, 85 45, 85 50" />
|
|
{/* Middle Top Winding */}
|
|
<path d="M 40 50 C 40 30, 50 25, 60 25 C 70 25, 75 30, 80 30" />
|
|
{/* Bottom Winding S-shape */}
|
|
<path d="M 18 50 C 18 85, 45 85, 45 65 C 45 45, 65 95, 85 50" />
|
|
|
|
{/* Ramos de Ligação (Atalhos) */}
|
|
<path d="M 15 15 L 28 15" strokeWidth="1.5" /> {/* Bloco A */}
|
|
<path d="M 8 80 L 27 80" strokeWidth="1.5" /> {/* Moradia 7 */}
|
|
<path d="M 20 90 L 20 60" strokeWidth="1.5" /> {/* Moradia 8 */}
|
|
<path d="M 12 40 L 12 50" strokeWidth="1.5" /> {/* Moradia 1 */}
|
|
<path d="M 35 10 L 35 15" strokeWidth="1.5" /> {/* Moradia 2 */}
|
|
<path d="M 45 35 L 45 29" strokeWidth="1.5" /> {/* Moradia 3 */}
|
|
<path d="M 55 80 L 55 64" strokeWidth="1.5" /> {/* Moradia 4 */}
|
|
<path d="M 25 60 L 25 78" strokeWidth="1.5" /> {/* Moradia 5 */}
|
|
<path d="M 65 30 L 65 26" strokeWidth="1.5" /> {/* Moradia 6 */}
|
|
<path d="M 75 65 L 75 68" strokeWidth="1.5" /> {/* Cafetaria */}
|
|
<path d="M 35 90 L 35 72" strokeWidth="1.5" /> {/* Clinica */}
|
|
<path d="M 60 55 L 60 68" strokeWidth="1.5" /> {/* Piscina */}
|
|
<path d="M 50 15 L 50 5" strokeWidth="1.5" /> {/* Parque */}
|
|
<path d="M 75 85 L 72 65" strokeWidth="1.5" /> {/* Ginasio */}
|
|
<path d="M 65 15 L 65 28" strokeWidth="1.5" /> {/* Salao */}
|
|
<path d="M 86 35 L 80 35" strokeWidth="1.5" /> {/* Barcos */}
|
|
<path d="M 88 75 L 78 60" strokeWidth="1.5" /> {/* Deque */}
|
|
</g>
|
|
<g stroke="#f8fafc" strokeWidth="0.5" strokeDasharray="1,1" className="dark:stroke-slate-400" strokeLinecap="round" strokeLinejoin="round" fill="none">
|
|
{/* Centros das Vias Orgânicas */}
|
|
<path d="M 25 50 C 25 15, 40 5, 55 5 C 75 5, 80 20, 80 30 C 80 40, 85 45, 85 50" />
|
|
<path d="M 40 50 C 40 30, 50 25, 60 25 C 70 25, 75 30, 80 30" />
|
|
<path d="M 18 50 C 18 85, 45 85, 45 65 C 45 45, 65 95, 85 50" />
|
|
</g>
|
|
</svg>
|
|
|
|
<div className="absolute top-[50%] left-0 w-[88%] h-12 transform -translate-y-1/2 flex items-center justify-center text-slate-500 dark:text-slate-300 font-black tracking-[0.8em] opacity-60 text-xs sm:text-sm pointer-events-none z-0">VIA CENTRAL</div>
|
|
|
|
{/* Á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) => (
|
|
<div key={`tree-${i}`} className="absolute w-6 h-6 bg-green-600/50 dark:bg-green-800/50 rounded-full blur-[2px] shadow-lg pointer-events-none z-0" style={{ left: `${tree.x}%`, top: `${tree.y}%` }}></div>
|
|
))}
|
|
|
|
{/* SVG Route Overlay */}
|
|
{route && route.targetId && (
|
|
<svg className="absolute inset-0 w-full h-full pointer-events-none z-30" style={{ overflow: 'visible' }}>
|
|
<defs>
|
|
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
<polygon points="0 0, 10 3.5, 0 7" fill="#3b82f6" />
|
|
</marker>
|
|
</defs>
|
|
{espacos.filter(e => e.id === route.targetId).map(target => {
|
|
const targetCX = target.cx !== undefined ? target.cx : target.x + (target.w||10) / 2;
|
|
const targetCY = target.cy !== undefined ? target.cy : target.y + (target.h||10) / 2;
|
|
return (
|
|
<path
|
|
key="route-path"
|
|
d={`M ${route.userX}% ${route.userY}% Q 50% 50% ${targetCX}% ${targetCY}%`}
|
|
fill="none"
|
|
stroke="#3b82f6"
|
|
strokeWidth="4"
|
|
strokeDasharray="8, 8"
|
|
className="animate-[dash_1s_linear_infinite]"
|
|
markerEnd="url(#arrowhead)"
|
|
/>
|
|
);
|
|
})}
|
|
<circle cx={`${route.userX}%`} cy={`${route.userY}%`} r="6" fill="#3b82f6" className="animate-ping" />
|
|
<circle cx={`${route.userX}%`} cy={`${route.userY}%`} r="4" fill="#1e40af" />
|
|
<style>{`
|
|
@keyframes dash { to { stroke-dashoffset: -16; } }
|
|
`}</style>
|
|
</svg>
|
|
)}
|
|
|
|
{espacos.map(loc => {
|
|
const IconComp = IconMap[loc.icone] || MapPin;
|
|
// Reduzir o tamanho em 25% (scale = 0.75) mantendo o centro
|
|
const scale = 0.75;
|
|
const w = (loc.w || 10) * scale;
|
|
const h = (loc.h || 10) * scale;
|
|
const cx = loc.cx !== undefined ? loc.cx : loc.x + (loc.w || 10) / 2;
|
|
const cy = loc.cy !== undefined ? loc.cy : loc.y + (loc.h || 10) / 2;
|
|
const x = cx - w / 2;
|
|
const y = cy - h / 2;
|
|
|
|
return (
|
|
<div
|
|
key={loc.id}
|
|
onClick={() => 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-2xl z-20 ring-4 ring-blue-400/50' : 'hover:scale-105 shadow-md z-10'} ${loc.isRound ? 'rounded-full' : 'rounded-xl bg-white/90 dark:bg-dark-surface/90 backdrop-blur-sm'}`}
|
|
style={{ left: `${x}%`, top: `${y}%`, width: `${w}%`, height: `${h}%` }}
|
|
>
|
|
<IconComp size={loc.isRound ? 14 : 20} className={`${loc.color} ${activePoint === loc.id ? 'animate-bounce' : ''}`} />
|
|
{!loc.isRound && (
|
|
<span className={`font-black text-[9px] sm:text-[10px] mt-1 text-center px-1 text-slate-800 dark:text-slate-200 leading-tight drop-shadow-sm`}>
|
|
{loc.nome}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabela lateral / Legenda */}
|
|
<div className="w-full lg:w-96 border-t lg:border-t-0 lg:border-l border-slate-200 dark:border-dark-border bg-white dark:bg-dark-surface overflow-y-auto flex flex-col h-[400px] lg:h-auto">
|
|
<div className="p-4 bg-slate-50 dark:bg-dark-card border-b border-slate-200 dark:border-dark-border sticky top-0 z-10">
|
|
<h4 className="font-bold text-slate-800 dark:text-white flex items-center gap-2">
|
|
<MapPin size={18} className="text-blue-600" /> Detalhes e Navegação
|
|
</h4>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">Selecione um ponto no mapa para ver rotas.</p>
|
|
</div>
|
|
<div className="flex-1 p-3 space-y-2">
|
|
{espacos.map(loc => {
|
|
const IconComp = IconMap[loc.icone] || MapPin;
|
|
return (
|
|
<div
|
|
key={`list-${loc.id}`}
|
|
onClick={() => 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'}`}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className={`p-2.5 rounded-lg border ${loc.bg} ${loc.border}`}>
|
|
<IconComp size={20} className={loc.color} />
|
|
</div>
|
|
<div className="flex-1 pt-1">
|
|
<div className="flex justify-between items-start">
|
|
<h5 className="font-bold text-sm text-slate-800 dark:text-white">{loc.nome}</h5>
|
|
<span className={`text-[9px] uppercase font-bold px-2 py-0.5 rounded-full ${loc.tipo === 'Residencial' ? 'bg-orange-100 text-orange-700' : loc.tipo === 'Comércio' ? 'bg-amber-100 text-amber-700' : loc.tipo === 'Lazer' ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-700'} dark:bg-opacity-20`}>{loc.tipo}</span>
|
|
</div>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">{loc.descricao}</p>
|
|
|
|
{activePoint === loc.id && (
|
|
<div className="mt-3 flex flex-col gap-2">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleRoute(loc); }}
|
|
disabled={isLocating}
|
|
className="w-full text-xs bg-indigo-600 text-white px-3 py-2 rounded-lg hover:bg-indigo-700 transition-colors shadow-sm flex items-center justify-center gap-1.5 font-bold disabled:opacity-50"
|
|
>
|
|
<Navigation size={14} /> {isLocating ? 'A localizar...' : 'Navegar até aqui'}
|
|
</button>
|
|
|
|
{loc.canBook && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleOpenModal('booking', null, loc.bookId); }}
|
|
className="w-full text-xs bg-blue-600 text-white px-3 py-2 rounded-lg hover:bg-blue-700 transition-colors shadow-sm flex items-center justify-center gap-1.5 font-bold"
|
|
>
|
|
<Calendar size={14} /> Fazer Reserva
|
|
</button>
|
|
)}
|
|
|
|
{route && route.targetId === loc.id && (
|
|
<div className="mt-2 bg-indigo-50 dark:bg-indigo-900/30 p-3 rounded-lg border border-indigo-100 dark:border-indigo-800/50 animate-fade-in">
|
|
<h6 className="text-[10px] uppercase font-bold text-indigo-800 dark:text-indigo-300 mb-2 border-b border-indigo-200 dark:border-indigo-700 pb-1">Detalhes da Rota</h6>
|
|
<div className="flex justify-between items-center text-xs text-slate-700 dark:text-slate-300">
|
|
<span className="flex items-center gap-1"><MapPin size={12} className="text-indigo-500"/> Distância:</span>
|
|
<span className="font-bold">{route.distance > 1000 ? (route.distance/1000).toFixed(1) + ' km' : Math.round(route.distance) + ' m'}</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-xs text-slate-700 dark:text-slate-300 mt-1.5">
|
|
<span className="flex items-center gap-1"><Info size={12} className="text-indigo-500"/> A pé:</span>
|
|
<span className="font-bold">{route.walkTime} min</span>
|
|
</div>
|
|
<div className="flex justify-between items-center text-xs text-slate-700 dark:text-slate-300 mt-1.5">
|
|
<span className="flex items-center gap-1"><Car size={12} className="text-indigo-500"/> De carro:</span>
|
|
<span className="font-bold">{route.driveTime} min</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const MaintenanceView = () => (
|
|
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-sm border border-slate-100 dark:border-dark-border overflow-hidden animate-fade-in flex flex-col h-full transition-colors">
|
|
<div className="p-6 border-b border-slate-100 dark:border-dark-border flex justify-between items-center">
|
|
<div>
|
|
<h3 className="font-bold text-lg text-slate-800 dark:text-white">Manutenção e Ocorrências</h3>
|
|
<p className="text-sm text-slate-500 dark:text-dark-mute">Gestão de pedidos e reparações</p>
|
|
</div>
|
|
{userRole === 'admin' ? (
|
|
<button
|
|
className="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 shadow-sm transition-colors cursor-default"
|
|
>
|
|
<CheckCircle size={18} /> Resolver Problemas
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => handleOpenModal('issue')}
|
|
className="bg-orange-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-orange-700 flex items-center gap-2 shadow-sm transition-colors"
|
|
>
|
|
<Plus size={18} /> Reportar Problema
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto p-6 bg-slate-50 dark:bg-dark-bg">
|
|
{issues.length === 0 ? (
|
|
<div className="text-center py-12 text-slate-400 dark:text-dark-mute border-2 border-dashed border-slate-200 dark:border-dark-border rounded-xl">
|
|
<Wrench size={48} className="mx-auto mb-2 opacity-20" />
|
|
<p>Sem ocorrências registadas.</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{issues.map(issue => (
|
|
<div key={issue.id} className="bg-white dark:bg-dark-surface rounded-lg shadow-sm border border-slate-200 dark:border-dark-border p-5 hover:shadow-md transition-shadow relative overflow-hidden">
|
|
<div className={`absolute top-0 left-0 w-1 h-full ${issue.priority === 'Alta' ? 'bg-red-500' :
|
|
issue.priority === 'Média' ? 'bg-orange-500' : 'bg-blue-500'
|
|
}`}></div>
|
|
|
|
<div className="flex justify-between items-start mb-3 pl-2">
|
|
<Badge status={issue.status} />
|
|
<span className="text-xs text-slate-400 dark:text-slate-500 font-mono">{issue.date}</span>
|
|
</div>
|
|
|
|
<h4 className="font-bold text-slate-800 dark:text-white text-lg mb-1 pl-2">{issue.title}</h4>
|
|
<p className="text-sm text-slate-600 dark:text-dark-mute mb-4 pl-2 flex items-center gap-2">
|
|
<MapPin size={14} /> {issue.location}
|
|
</p>
|
|
|
|
<div className="pt-4 border-t border-slate-100 dark:border-dark-border flex justify-between items-center pl-2">
|
|
<span className={`text-xs font-bold px-2 py-1 rounded ${issue.priority === 'Alta' ? 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400' :
|
|
issue.priority === 'Média' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-400' : 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400'
|
|
}`}>
|
|
Prioridade {issue.priority}
|
|
</span>
|
|
|
|
{userRole === 'admin' && issue.status !== 'Resolvido' && (
|
|
<button
|
|
onClick={() => handleResolveIssue(issue.id)}
|
|
className="text-sm text-green-600 dark:text-green-400 font-medium hover:text-green-700 dark:hover:text-green-300 flex items-center gap-1"
|
|
>
|
|
<CheckCircle size={16} /> Resolver
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
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',
|
|
photoUrl: currentUserData.photoUrl || ''
|
|
});
|
|
} 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);
|
|
if (formData.photoUrl !== undefined) {
|
|
await set(ref(db, `condominos/${currentUserData.id}/photoUrl`), formData.photoUrl);
|
|
}
|
|
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');
|
|
}
|
|
}
|
|
};
|
|
|
|
const fileInputRef = React.useRef(null);
|
|
const handleImageChange = (e) => {
|
|
const file = e.target.files[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
handleChange('photoUrl', reader.result);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-sm border border-slate-100 dark:border-dark-border overflow-hidden animate-fade-in flex flex-col h-full transition-colors">
|
|
<div className="flex flex-col md:flex-row h-full">
|
|
{/* Profile Sidebar */}
|
|
<div className="w-full md:w-64 bg-slate-50 dark:bg-dark-bg border-r border-slate-100 dark:border-dark-border p-6 flex flex-col gap-2">
|
|
<div className="text-center mb-6">
|
|
<div
|
|
className="w-24 h-24 bg-blue-100 dark:bg-blue-900/30 rounded-full mx-auto flex items-center justify-center text-blue-600 dark:text-blue-400 font-bold text-3xl mb-3 border-4 border-white dark:border-dark-surface shadow-sm cursor-pointer relative group overflow-hidden transition-all"
|
|
onClick={() => fileInputRef.current && fileInputRef.current.click()}
|
|
>
|
|
{formData.photoUrl ? (
|
|
<img src={formData.photoUrl} alt="Perfil" className="w-full h-full object-cover" />
|
|
) : (
|
|
userRole === 'admin' ? 'AD' : 'MO'
|
|
)}
|
|
<div className="absolute inset-0 bg-black/50 hidden group-hover:flex items-center justify-center text-white transition-all">
|
|
<span className="text-xs font-medium">Alterar</span>
|
|
</div>
|
|
</div>
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
onChange={handleImageChange}
|
|
accept="image/*"
|
|
className="hidden"
|
|
/>
|
|
<h3 className="font-bold text-slate-800 dark:text-white">{userRole === 'admin' ? 'Admin Condomínio' : 'Morador'}</h3>
|
|
<p className="text-xs text-slate-500 dark:text-dark-mute">{userRole === 'admin' ? 'Administrador Geral' : 'Residente'}</p>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setActiveSection('personal')}
|
|
className={`text-left px-4 py-3 rounded-lg text-sm font-medium transition-colors flex items-center gap-3 ${activeSection === 'personal' ? 'bg-white dark:bg-dark-surface text-blue-600 dark:text-blue-400 shadow-sm' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-dark-card'}`}
|
|
>
|
|
<Users size={18} /> Dados Pessoais
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveSection('security')}
|
|
className={`text-left px-4 py-3 rounded-lg text-sm font-medium transition-colors flex items-center gap-3 ${activeSection === 'security' ? 'bg-white dark:bg-dark-surface text-blue-600 dark:text-blue-400 shadow-sm' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-dark-card'}`}
|
|
>
|
|
<LogOut size={18} className="rotate-90" /> Segurança
|
|
</button>
|
|
{userRole === 'admin' && (
|
|
<button
|
|
onClick={() => setActiveSection('permissions')}
|
|
className={`text-left px-4 py-3 rounded-lg text-sm font-medium transition-colors flex items-center gap-3 ${activeSection === 'permissions' ? 'bg-white dark:bg-dark-surface text-blue-600 dark:text-blue-400 shadow-sm' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-dark-card'}`}
|
|
>
|
|
<CheckCircle size={18} /> Permissões
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setActiveSection('settings')}
|
|
className={`text-left px-4 py-3 rounded-lg text-sm font-medium transition-colors flex items-center gap-3 ${activeSection === 'settings' ? 'bg-white dark:bg-dark-surface text-blue-600 dark:text-blue-400 shadow-sm' : 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-dark-card'}`}
|
|
>
|
|
<Wrench size={18} /> Preferências
|
|
</button>
|
|
</div>
|
|
|
|
{/* Profile Content */}
|
|
<div className="flex-1 p-8 overflow-y-auto">
|
|
{activeSection === 'personal' && (
|
|
<div className="animate-fade-in max-w-2xl">
|
|
<h3 className="text-lg font-bold text-slate-800 dark:text-white mb-6 pb-2 border-b border-slate-100 dark:border-dark-border">Dados Pessoais</h3>
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<InputGroup label="Nome Completo" value={formData.name} onChange={(e) => handleChange('name', e.target.value)} disabled={isMorador} />
|
|
<InputGroup label={isMorador ? "Fração" : "Cargo"} value={formData.role} onChange={(e) => handleChange('role', e.target.value)} disabled={isMorador} />
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<InputGroup label="Email" value={formData.email} onChange={(e) => handleChange('email', e.target.value)} type="email" />
|
|
<InputGroup label="Telefone" value={formData.contact} onChange={(e) => handleChange('contact', e.target.value)} />
|
|
</div>
|
|
<InputGroup label={isMorador ? "Morada" : "Morada (Sede)"} value={formData.address} onChange={(e) => handleChange('address', e.target.value)} disabled={isMorador} />
|
|
<div className="flex justify-end mt-6">
|
|
<button onClick={handleSave} className="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 shadow-sm transition-colors">
|
|
Guardar Alterações
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeSection === 'security' && (
|
|
<div className="animate-fade-in max-w-2xl">
|
|
<h3 className="text-lg font-bold text-slate-800 dark:text-white mb-6 pb-2 border-b border-slate-100 dark:border-dark-border">Segurança</h3>
|
|
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4 mb-6 flex items-start gap-3">
|
|
<AlertCircle className="text-orange-600 dark:text-orange-400 flex-shrink-0 mt-0.5" size={20} />
|
|
<div>
|
|
<h4 className="font-bold text-orange-800 dark:text-orange-300 text-sm">Autenticação de Dois Fatores (2FA)</h4>
|
|
<p className="text-xs text-orange-700 dark:text-orange-400 mt-1">Recomendamos ativar o 2FA para maior segurança da sua conta.</p>
|
|
<button onClick={() => {
|
|
showNotification('Autenticação de Dois Fatores ativada com sucesso!', 'success');
|
|
sendSystemNotification('Um utilizador ativou a autenticação 2FA.', 'success', 'admin');
|
|
}} className="text-orange-900 dark:text-orange-200 text-xs font-bold underline mt-2">Ativar Agora</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<InputGroup label="Palavra-passe Atual" type="password" placeholder="••••••••" value={passwordData.current} onChange={(e) => handlePasswordChange('current', e.target.value)} />
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<InputGroup label="Nova Palavra-passe" type="password" placeholder="Min. 8 caracteres" value={passwordData.new} onChange={(e) => handlePasswordChange('new', e.target.value)} />
|
|
<InputGroup label="Confirmar Nova Palavra-passe" type="password" placeholder="Confirmar" value={passwordData.confirm} onChange={(e) => handlePasswordChange('confirm', e.target.value)} />
|
|
</div>
|
|
<div className="flex justify-end mt-6">
|
|
<button onClick={handleSavePassword} className="bg-slate-800 dark:bg-slate-700 text-white px-6 py-2 rounded-lg font-medium hover:bg-slate-900 dark:hover:bg-slate-600 shadow-sm transition-colors">
|
|
Atualizar Segurança
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeSection === 'permissions' && (
|
|
<div className="animate-fade-in max-w-2xl">
|
|
<h3 className="text-lg font-bold text-slate-800 dark:text-white mb-6 pb-2 border-b border-slate-100 dark:border-dark-border">Nível de Acesso</h3>
|
|
|
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-6 flex items-center gap-3">
|
|
<div className="bg-green-100 dark:bg-green-800/50 p-2 rounded-full">
|
|
<CheckCircle className="text-green-600 dark:text-green-400" size={24} />
|
|
</div>
|
|
<div>
|
|
<h4 className="font-bold text-green-800 dark:text-green-300">Acesso Total (Admin)</h4>
|
|
<p className="text-xs text-green-700 dark:text-green-400">Tem permissões totais para gerir condóminos, finanças e configurações.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-bold text-slate-700 dark:text-slate-300 mb-2">Permissões Específicas:</h4>
|
|
{['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) => (
|
|
<div key={idx} className="flex items-center gap-3 p-3 border border-slate-100 dark:border-dark-border rounded-lg bg-slate-50 dark:bg-dark-card">
|
|
<div className="w-2 h-2 rounded-full bg-green-500"></div>
|
|
<span className="text-sm text-slate-600 dark:text-slate-300">{perm}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeSection === 'settings' && (
|
|
<div className="animate-fade-in max-w-2xl">
|
|
<h3 className="text-lg font-bold text-slate-800 dark:text-white mb-6 pb-2 border-b border-slate-100 dark:border-dark-border">Preferências da Aplicação</h3>
|
|
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h4 className="text-sm font-bold text-slate-700 dark:text-slate-300 mb-3">Notificações</h4>
|
|
<div className="space-y-2">
|
|
<label className="flex items-center gap-3 p-3 border border-slate-200 dark:border-dark-border rounded-lg cursor-pointer hover:bg-slate-50 dark:hover:bg-dark-card transition-colors">
|
|
<input type="checkbox" checked className="w-4 h-4 text-blue-600 rounded" readOnly />
|
|
<span className="text-sm text-slate-700 dark:text-slate-300">Alertas por Email</span>
|
|
</label>
|
|
<label className="flex items-center gap-3 p-3 border border-slate-200 dark:border-dark-border rounded-lg cursor-pointer hover:bg-slate-50 dark:hover:bg-dark-card transition-colors">
|
|
<input type="checkbox" checked className="w-4 h-4 text-blue-600 rounded" readOnly />
|
|
<span className="text-sm text-slate-700 dark:text-slate-300">Notificações Push no Navegador</span>
|
|
</label>
|
|
<label className="flex items-center gap-3 p-3 border border-slate-200 dark:border-dark-border rounded-lg cursor-pointer hover:bg-slate-50 dark:hover:bg-dark-card transition-colors">
|
|
<input type="checkbox" className="w-4 h-4 text-blue-600 rounded" readOnly />
|
|
<span className="text-sm text-slate-700 dark:text-slate-300">Relatórios Semanais Automáticos</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 className="text-sm font-bold text-slate-700 dark:text-slate-300 mb-3">Idioma da Aplicação</h4>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
{[
|
|
{ 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 (
|
|
<div
|
|
key={lang.code}
|
|
onClick={() => {
|
|
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'}`}
|
|
>
|
|
<div className="text-2xl mb-1">{lang.flag}</div>
|
|
<div className="text-xs font-bold text-slate-700 dark:text-slate-300">{lang.label}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">A tradução é efetuada automaticamente e afetará toda a aplicação após recarregar a página.</p>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 className="text-sm font-bold text-slate-700 mb-3">Aparência</h4>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div
|
|
onClick={() => 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`}
|
|
>
|
|
<div className="w-full h-12 bg-white border border-slate-200 rounded mb-2 shadow-sm"></div>
|
|
<span className={`text-xs font-bold ${theme === 'light' ? 'text-blue-600' : 'text-slate-500'}`}>Claro</span>
|
|
</div>
|
|
<div
|
|
onClick={() => 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`}
|
|
>
|
|
<div className="w-full h-12 bg-slate-800 rounded mb-2 shadow-sm border border-slate-700"></div>
|
|
<span className={`text-xs font-bold ${theme === 'dark' ? 'text-blue-600' : 'text-slate-500'}`}>Escuro</span>
|
|
</div>
|
|
<div
|
|
onClick={() => 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`}
|
|
>
|
|
<div className="w-full h-12 bg-gradient-to-r from-white to-slate-800 rounded mb-2 shadow-sm border border-slate-200"></div>
|
|
<span className={`text-xs font-bold ${theme === 'system' ? 'text-blue-600' : 'text-slate-500'}`}>Sistema</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (!isAuthenticated) {
|
|
return <LoginView onLogin={handleLogin} />;
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-screen bg-slate-50 dark:bg-dark-bg text-slate-800 dark:text-dark-text overflow-hidden font-sans transition-colors duration-300">
|
|
{/* Mobile Overlay */}
|
|
{isSidebarOpen && (
|
|
<div className="fixed inset-0 z-20 bg-black/50 lg:hidden" onClick={toggleSidebar}></div>
|
|
)}
|
|
|
|
{/* Sidebar */}
|
|
<aside className={`fixed inset-y-0 left-0 z-30 w-64 bg-white dark:bg-dark-surface border-r border-slate-200 dark:border-dark-border shadow-xl transform transition-transform duration-300 lg:translate-x-0 lg:static lg:shadow-none ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
|
<div className="flex flex-col h-full">
|
|
<div className="p-6 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-blue-600 rounded-lg text-white">
|
|
<Building2 size={24} />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-bold text-slate-800 dark:text-white">MyCondominium</h1>
|
|
<p className="text-xs text-slate-400 dark:text-dark-mute">Portal de Gestão</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={toggleSidebar} className="lg:hidden text-slate-400"><X size={24} /></button>
|
|
</div>
|
|
|
|
<div className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
|
|
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 px-4 mt-2">Geral</div>
|
|
<SidebarItem icon={Building2} label="Dashboard" active={activeTab === 'dashboard'} onClick={() => { setActiveTab('dashboard'); setSidebarOpen(false); }} />
|
|
{userRole === 'admin' && <SidebarItem icon={Users} label="Condóminos" active={activeTab === 'residents'} onClick={() => { setActiveTab('residents'); setSidebarOpen(false); }} />}
|
|
{userRole === 'admin' && <SidebarItem icon={Wallet} label="Finanças" active={activeTab === 'finance'} onClick={() => { setActiveTab('finance'); setSidebarOpen(false); }} />}
|
|
{userRole === 'admin' && <SidebarItem icon={FileText} label="Faturação" active={activeTab === 'billing'} onClick={() => { setActiveTab('billing'); setSidebarOpen(false); }} />}
|
|
{userRole === 'admin' && <SidebarItem icon={CheckCircle} label="Pagamentos" active={activeTab === 'approvals'} onClick={() => { setActiveTab('approvals'); setSidebarOpen(false); }} />}
|
|
|
|
{userRole === 'morador' && <SidebarItem icon={Wallet} label="Minhas Contas" active={activeTab === 'minhas_contas'} onClick={() => { setActiveTab('minhas_contas'); setSidebarOpen(false); }} />}
|
|
<SidebarItem icon={Wrench} label="Manutenção" active={activeTab === 'maintenance'} onClick={() => { setActiveTab('maintenance'); setSidebarOpen(false); }} />
|
|
<SidebarItem icon={MessageCircle} label="Mensagens" active={activeTab === 'messages'} onClick={() => { setActiveTab('messages'); setSidebarOpen(false); }} />
|
|
<SidebarItem icon={Map} label="Mapa" active={activeTab === 'map'} onClick={() => { setActiveTab('map'); setSidebarOpen(false); }} />
|
|
|
|
<div className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 px-4 mt-6">Espaços Comuns</div>
|
|
<SidebarItem icon={Dumbbell} label="Ginásio" active={activeTab === 'gym'} onClick={() => { setActiveTab('gym'); setSidebarOpen(false); }} />
|
|
<SidebarItem icon={PartyPopper} label="Salão de Festas" active={activeTab === 'hall'} onClick={() => { setActiveTab('hall'); setSidebarOpen(false); }} />
|
|
<SidebarItem icon={Trophy} label="Parque Jogos" active={activeTab === 'park'} onClick={() => { setActiveTab('park'); setSidebarOpen(false); }} />
|
|
</div>
|
|
|
|
<div className="p-4 border-t border-slate-100 dark:border-dark-border">
|
|
<button onClick={handleLogout} className="flex items-center gap-3 px-4 py-3 text-sm font-medium text-red-600 hover:bg-red-50 w-full rounded-lg transition-colors">
|
|
<LogOut size={20} />
|
|
<span>Terminar Sessão</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main Content */}
|
|
<main className="flex-1 flex flex-col h-full overflow-hidden relative">
|
|
{/* Header */}
|
|
<header className="h-16 bg-white dark:bg-dark-surface border-b border-slate-200 dark:border-dark-border flex items-center justify-between px-6 z-10 shadow-sm transition-colors duration-300">
|
|
<div className="flex items-center gap-4">
|
|
<button onClick={toggleSidebar} className="lg:hidden text-slate-600 dark:text-slate-300 hover:text-blue-600 p-2 -ml-2 rounded-lg hover:bg-slate-100 dark:hover:bg-dark-card">
|
|
<Menu size={24} />
|
|
</button>
|
|
<h2 className="text-xl font-bold text-slate-800 dark:text-white capitalize">{
|
|
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
|
|
}</h2>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<div className="relative hidden md:block">
|
|
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Pesquisar..."
|
|
className="pl-10 pr-4 py-2 border border-slate-200 dark:border-dark-border rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400 w-64 bg-white dark:bg-dark-card dark:text-white dark:placeholder-slate-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* Notifications */}
|
|
<div className="relative" ref={notificationRef}>
|
|
<button
|
|
className="p-2 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-dark-card rounded-full relative transition-colors"
|
|
onClick={() => setNotificationsOpen(!isNotificationsOpen)}
|
|
>
|
|
<Bell size={20} />
|
|
{unreadNotifications > 0 && (
|
|
<span className="absolute top-1 right-1 w-2.5 h-2.5 bg-red-500 rounded-full ring-2 ring-white dark:ring-dark-surface"></span>
|
|
)}
|
|
</button>
|
|
|
|
{isNotificationsOpen && (
|
|
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-dark-surface rounded-xl shadow-xl border border-slate-100 dark:border-dark-border overflow-hidden animate-slide-up origin-top-right z-50">
|
|
<div className="p-4 border-b border-slate-100 dark:border-dark-border flex justify-between items-center">
|
|
<h3 className="font-bold text-slate-800 dark:text-white">Notificações</h3>
|
|
<button onClick={handleClearNotifications} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">Limpar</button>
|
|
</div>
|
|
<div className="max-h-80 overflow-y-auto">
|
|
{notificationsList.length === 0 ? (
|
|
<div className="p-8 text-center text-slate-400 dark:text-dark-mute text-sm">Sem novas notificações</div>
|
|
) : (
|
|
notificationsList.map(notif => (
|
|
<div key={notif.id} className={`p-4 border-b border-slate-50 dark:border-dark-border hover:bg-slate-50 dark:hover:bg-dark-card ${!notif.read ? 'bg-blue-50/30 dark:bg-blue-900/10' : ''}`}>
|
|
<div className="flex gap-3">
|
|
<div className={`mt-1 w-2 h-2 rounded-full flex-shrink-0 ${notif.type === 'info' ? 'bg-blue-500' : notif.type === 'success' ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
|
<div>
|
|
<p className="text-sm text-slate-700 dark:text-slate-200 leading-tight">{notif.message}</p>
|
|
<div className="flex justify-between items-center mt-1">
|
|
<p className="text-xs text-slate-400 dark:text-slate-500">{notif.time}</p>
|
|
{!notif.read && (
|
|
<button onClick={() => handleMarkAsRead(notif.id)} className="text-[10px] text-blue-600 dark:text-blue-400 hover:underline">
|
|
Marcar como lida
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700/50 flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-sm cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
|
|
onClick={() => setActiveTab('profile')}
|
|
title="Meu Perfil"
|
|
>
|
|
{(() => {
|
|
const currentUser = residents.find(r => r.id === currentUserId);
|
|
const photoUrl = userRole === 'admin' ? adminProfile?.photoUrl : currentUser?.photoUrl;
|
|
if (photoUrl) return <img src={photoUrl} alt="Perfil" className="w-full h-full rounded-full object-cover border border-slate-200 dark:border-slate-700" />;
|
|
return userRole === 'admin' ? 'AD' : 'MO';
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Content Body */}
|
|
<div className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8 bg-slate-50 dark:bg-dark-bg scroll-smooth transition-colors duration-300">
|
|
<div className="max-w-7xl mx-auto h-full">
|
|
|
|
{/* --- DASHBOARD --- */}
|
|
{activeTab === 'dashboard' && <DashboardView />}
|
|
|
|
{/* --- MAPA --- */}
|
|
{activeTab === 'map' && <MapView />}
|
|
|
|
{/* --- GINÁSIO --- */}
|
|
{activeTab === 'gym' && (
|
|
<BookingView
|
|
facilityType="gym"
|
|
title="Ginásio Privado"
|
|
icon={Dumbbell}
|
|
description="Equipamento cardio e musculação disponível para todos os residentes."
|
|
priceInfo="Gratuito"
|
|
color="bg-gradient-to-r from-blue-500 to-blue-600"
|
|
/>
|
|
)}
|
|
|
|
{/* --- SALÃO --- */}
|
|
{activeTab === 'hall' && (
|
|
<BookingView
|
|
facilityType="hall"
|
|
title="Salão de Festas"
|
|
icon={PartyPopper}
|
|
description="Espaço amplo para eventos, aniversários e reuniões de condomínio."
|
|
priceInfo="50€ / Evento"
|
|
color="bg-gradient-to-r from-purple-500 to-purple-600"
|
|
/>
|
|
)}
|
|
|
|
{/* --- PARQUE --- */}
|
|
{activeTab === 'park' && (
|
|
<BookingView
|
|
facilityType="park"
|
|
title="Parque de Jogos"
|
|
icon={Trophy}
|
|
description="Campo polidesportivo exterior (Futebol, Basquetebol e Ténis)."
|
|
priceInfo="10€ / Hora"
|
|
color="bg-gradient-to-r from-green-500 to-green-600"
|
|
/>
|
|
)}
|
|
|
|
{/* --- ALL BOOKINGS --- */}
|
|
{activeTab === 'all_bookings' && (
|
|
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-sm border border-slate-100 dark:border-dark-border overflow-hidden animate-fade-in flex flex-col h-full transition-colors">
|
|
<div className="p-6 border-b border-slate-100 dark:border-dark-border flex justify-between items-center">
|
|
<div>
|
|
<h3 className="font-bold text-lg text-slate-800 dark:text-white">Histórico de Reservas</h3>
|
|
<p className="text-sm text-slate-500 dark:text-dark-mute">Lista completa de agendamentos em todos os espaços de lazer</p>
|
|
</div>
|
|
<button onClick={() => setActiveTab('map')} className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 flex items-center justify-center gap-2 shadow-sm transition-colors">
|
|
<Map size={18} /> Nova Reserva
|
|
</button>
|
|
</div>
|
|
<div className="overflow-auto flex-1 p-4 space-y-3">
|
|
{bookings.map(booking => (
|
|
<div key={booking.id} className="flex items-center justify-between p-4 border border-slate-100 dark:border-dark-border rounded-lg hover:bg-slate-50 dark:hover:bg-dark-card transition-colors shadow-sm">
|
|
<div className="flex items-center gap-4">
|
|
<div className={`p-3 rounded-lg ${booking.facility === 'gym' ? 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' : booking.facility === 'hall' ? 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400' : 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400'}`}>
|
|
{booking.facility === 'gym' ? <Dumbbell size={24} /> : booking.facility === 'hall' ? <PartyPopper size={24} /> : <Trophy size={24} />}
|
|
</div>
|
|
<div>
|
|
<p className="font-bold text-base text-slate-800 dark:text-slate-200">{booking.facilityName}</p>
|
|
<p className="text-sm text-slate-600 dark:text-slate-400">{booking.date} • {booking.time}</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-base font-medium text-slate-700 dark:text-slate-300">{booking.resident}</p>
|
|
<div className="mt-1">
|
|
<Badge status={booking.status} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{bookings.length === 0 && (
|
|
<div className="text-center py-12">
|
|
<Calendar size={48} className="mx-auto text-slate-300 dark:text-slate-600 mb-4" />
|
|
<h3 className="text-lg font-bold text-slate-700 dark:text-slate-300">Sem reservas</h3>
|
|
<p className="text-slate-500 dark:text-slate-400 mt-2">Ainda não existem agendamentos no condomínio.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* --- APPROVALS --- */}
|
|
{activeTab === 'approvals' && userRole === 'admin' && (
|
|
<div className="p-6">
|
|
<div className="mb-6">
|
|
<h2 className="text-2xl font-bold text-slate-800 dark:text-white">Pagamentos Concluídos</h2>
|
|
<p className="text-slate-500 dark:text-dark-mute">Consulte o histórico de todos os pagamentos concluídos pelos condóminos.</p>
|
|
</div>
|
|
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-sm border border-slate-100 dark:border-dark-border overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr className="bg-slate-50 dark:bg-dark-bg border-b border-slate-100 dark:border-dark-border text-sm font-semibold text-slate-500 dark:text-slate-400">
|
|
<th className="p-4">Morador</th>
|
|
<th className="p-4">Fatura</th>
|
|
<th className="p-4">Estado</th>
|
|
<th className="p-4 text-right">Valor</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{faturas.filter(f => f.status === 'Pago').map(fatura => (
|
|
<tr key={fatura.id} className="border-b border-slate-50 dark:border-dark-border hover:bg-slate-50/50 dark:hover:bg-dark-bg/50">
|
|
<td className="p-4">
|
|
<p className="font-semibold text-slate-700 dark:text-slate-200">{fatura.nomeMorador}</p>
|
|
<p className="text-xs text-slate-400">Fração: {fatura.fracao}</p>
|
|
</td>
|
|
<td className="p-4 text-slate-600 dark:text-slate-400">
|
|
<p className="text-sm">{fatura.categoria}</p>
|
|
<p className="text-xs">Venceu a: {fatura.dataVencimento}</p>
|
|
</td>
|
|
<td className="p-4 text-slate-600 dark:text-slate-400">
|
|
<div className="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 text-xs font-medium">
|
|
<CheckCircle size={14} /> Pago
|
|
</div>
|
|
</td>
|
|
<td className="p-4 font-bold text-green-600 dark:text-green-400 text-right">{Number(fatura.valor).toFixed(2)}€</td>
|
|
</tr>
|
|
))}
|
|
{faturas.filter(f => f.status === 'Pago').length === 0 && (
|
|
<tr><td colSpan="4" className="p-8 text-center text-slate-500">Nenhum pagamento concluído encontrado.</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* --- RESIDENTS --- */}
|
|
{activeTab === 'residents' && (
|
|
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-sm border border-slate-100 dark:border-dark-border overflow-hidden animate-fade-in flex flex-col h-full transition-colors">
|
|
<div className="p-6 border-b border-slate-100 dark:border-dark-border flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div>
|
|
<h3 className="font-bold text-lg text-slate-800 dark:text-white">Gestão de Condóminos</h3>
|
|
<p className="text-sm text-slate-500 dark:text-dark-mute">Total: {residents.length} frações registadas</p>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
|
<input
|
|
type="text"
|
|
placeholder="Procurar fração ou nome..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-10 pr-4 py-2 border border-slate-200 dark:border-dark-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-full sm:w-64 bg-white dark:bg-dark-card dark:text-white dark:placeholder-slate-500 transition-colors"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => handleOpenModal('resident')}
|
|
className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 flex items-center justify-center gap-2 transition-colors shadow-sm"
|
|
>
|
|
<Plus size={18} /> <span className="hidden sm:inline">Adicionar</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-x-auto flex-1">
|
|
<table className="w-full text-sm text-left">
|
|
<thead className="bg-slate-50 dark:bg-dark-bg text-slate-500 dark:text-slate-400 font-medium border-b border-slate-100 dark:border-dark-border sticky top-0">
|
|
<tr>
|
|
<th className="px-6 py-4">Fração</th>
|
|
<th className="px-6 py-4">Proprietário</th>
|
|
<th className="px-6 py-4">Contacto</th>
|
|
<th className="px-6 py-4">Estado Quotas</th>
|
|
<th className="px-6 py-4 text-center">Acesso</th>
|
|
<th className="px-6 py-4 text-right">Em Dívida</th>
|
|
<th className="px-6 py-4 text-center">Ações</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100 dark:divide-dark-border">
|
|
{filteredResidents.map((resident) => (
|
|
<tr key={resident.id} className="hover:bg-slate-50 dark:hover:bg-dark-bg transition-colors group">
|
|
<td className="px-6 py-4 font-bold text-slate-700 dark:text-slate-200">{resident.unit}</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex flex-col">
|
|
<span className="text-slate-900 dark:text-white font-medium">{resident.name}</span>
|
|
<span className="text-xs text-slate-500 dark:text-dark-mute">{resident.email}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-slate-600 dark:text-dark-mute">{resident.contact}</td>
|
|
<td className="px-6 py-4"><Badge status={resident.status} /></td>
|
|
<td className="px-6 py-4 text-center">
|
|
<button
|
|
onClick={() => handleToggleRole(resident.id)}
|
|
className={`px-3 py-1 rounded-full text-xs font-bold transition-colors ${resident.role === 'admin' ? 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:hover:bg-purple-900/50' : 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700'}`}
|
|
title="Mudar Permissões"
|
|
>
|
|
{resident.role === 'admin' ? 'Admin' : 'Morador'}
|
|
</button>
|
|
</td>
|
|
<td className={`px-6 py-4 text-right font-medium ${resident.pending > 0 ? 'text-red-600 dark:text-red-400' : 'text-slate-600 dark:text-slate-400'}`}>
|
|
{Number(resident.pending).toFixed(2)}€
|
|
</td>
|
|
<td className="px-6 py-4 text-center">
|
|
<div className="flex items-center justify-center gap-2 opacity-100 sm:opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={() => handleOpenModal('resident', resident)}
|
|
className="p-1.5 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded-lg transition-colors" title="Editar"
|
|
>
|
|
<Edit2 size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteResident(resident.id)}
|
|
className="p-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors" title="Eliminar"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* --- BILLING / COBRANÇAS --- */}
|
|
{activeTab === 'billing' && userRole === 'admin' && (
|
|
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-sm border border-slate-100 dark:border-dark-border overflow-hidden animate-fade-in flex flex-col h-full transition-colors">
|
|
<div className="p-6 border-b border-slate-100 dark:border-dark-border flex flex-col md:flex-row justify-between items-center gap-4">
|
|
<div>
|
|
<h3 className="font-bold text-lg text-slate-800 dark:text-white">Avisos de Cobrança</h3>
|
|
<p className="text-sm text-slate-500 dark:text-dark-mute">Emita faturas ou avise condóminos individualmente</p>
|
|
</div>
|
|
<button onClick={() => handleOpenModal('emitir_fatura')} className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 flex items-center justify-center gap-2 shadow-sm">
|
|
<FileText size={18} /> Emitir Fatura
|
|
</button>
|
|
</div>
|
|
<div className="overflow-x-auto flex-1">
|
|
<table className="w-full text-sm text-left">
|
|
<thead className="bg-slate-50 dark:bg-dark-bg text-slate-500 dark:text-slate-400 font-medium border-b border-slate-100 dark:border-dark-border">
|
|
<tr>
|
|
<th className="px-6 py-4">Fração</th>
|
|
<th className="px-6 py-4">Condómino</th>
|
|
<th className="px-6 py-4">Quotas em Atraso</th>
|
|
<th className="px-6 py-4 text-center">Ações</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100 dark:divide-dark-border">
|
|
{residents.map((resident) => (
|
|
<tr key={resident.id} className="hover:bg-slate-50 dark:hover:bg-dark-bg transition-colors">
|
|
<td className="px-6 py-4 font-bold text-slate-700 dark:text-slate-200">{resident.unit}</td>
|
|
<td className="px-6 py-4 text-slate-800 dark:text-white">{resident.name}</td>
|
|
<td className={`px-6 py-4 font-medium ${resident.pending > 0 ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-500'}`}>
|
|
{resident.pending > 0 ? `${Number(resident.pending).toFixed(2)}€` : 'Regularizado'}
|
|
</td>
|
|
<td className="px-6 py-4 text-center">
|
|
<button
|
|
onClick={() => {
|
|
showNotification(`Aviso de cobrança enviado para ${resident.email}`, 'success');
|
|
sendSystemNotification(`Aviso de cobrança enviado a ${resident.name} para a fração ${resident.unit}.`, 'info', resident.id);
|
|
}}
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-colors shadow-sm cursor-pointer ${resident.pending > 0 ? 'bg-orange-600 text-white hover:bg-orange-700' : 'bg-slate-200 text-slate-400 cursor-not-allowed dark:bg-slate-800 dark:text-slate-600'}`}
|
|
disabled={resident.pending <= 0}
|
|
>
|
|
Notificar
|
|
</button>
|
|
<button
|
|
onClick={() => handleGenerateInvoice(resident)}
|
|
className="ml-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-colors shadow-sm bg-slate-800 text-white hover:bg-slate-900 dark:bg-slate-700 dark:hover:bg-slate-600"
|
|
>
|
|
Faturar na Hora
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
showNotification(`Fatura enviada com sucesso para ${resident.email}`, 'success');
|
|
sendSystemNotification(`Fatura enviada a ${resident.name} (${resident.unit}) por email.`, 'info', resident.id);
|
|
}}
|
|
className="ml-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-colors shadow-sm bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700"
|
|
>
|
|
Enviar por Email
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* --- MINHAS CONTAS (Morador) --- */}
|
|
{activeTab === 'minhas_contas' && userRole === 'morador' && (
|
|
<div className="space-y-6 animate-fade-in h-full flex flex-col">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="bg-white dark:bg-dark-surface p-6 rounded-xl shadow-sm border border-slate-100 dark:border-dark-border border-l-4 border-l-orange-500 relative overflow-hidden transition-colors">
|
|
<div className="relative z-10">
|
|
<h4 className="text-slate-500 dark:text-dark-mute text-xs font-bold uppercase tracking-wider">Total Pendente</h4>
|
|
<p className="text-2xl font-bold text-slate-800 dark:text-white mt-1">
|
|
{faturas.filter(f => f.moradorId === currentUserId && f.status === 'Pendente').reduce((acc, f) => acc + Number(f.valor), 0).toFixed(2)}€
|
|
</p>
|
|
</div>
|
|
<AlertCircle className="absolute right-4 bottom-4 text-orange-100 dark:text-orange-900/20" size={64} />
|
|
</div>
|
|
<div className="bg-white dark:bg-dark-surface p-6 rounded-xl shadow-sm border border-slate-100 dark:border-dark-border border-l-4 border-l-green-500 relative overflow-hidden transition-colors">
|
|
<div className="relative z-10">
|
|
<h4 className="text-slate-500 dark:text-dark-mute text-xs font-bold uppercase tracking-wider">Total Pago</h4>
|
|
<p className="text-2xl font-bold text-slate-800 dark:text-white mt-1">
|
|
{faturas.filter(f => f.moradorId === currentUserId && f.status === 'Pago').reduce((acc, f) => acc + Number(f.valor), 0).toFixed(2)}€
|
|
</p>
|
|
</div>
|
|
<CheckCircle className="absolute right-4 bottom-4 text-green-100 dark:text-green-900/20" size={64} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-sm border border-slate-100 dark:border-dark-border overflow-hidden flex-1 flex flex-col transition-colors">
|
|
<div className="p-6 border-b border-slate-100 dark:border-dark-border flex flex-col md:flex-row justify-between items-center gap-4">
|
|
<div>
|
|
<h3 className="font-bold text-lg text-slate-800 dark:text-white">Minhas Faturas</h3>
|
|
<p className="text-sm text-slate-500 dark:text-dark-mute">Consulte as suas despesas e faturas emitidas</p>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-x-auto flex-1">
|
|
<table className="w-full text-sm text-left">
|
|
<thead className="bg-slate-50 dark:bg-dark-bg text-slate-500 dark:text-slate-400 font-medium border-b border-slate-100 dark:border-dark-border">
|
|
<tr>
|
|
<th className="px-6 py-4">Data Emissão</th>
|
|
<th className="px-6 py-4">Categoria</th>
|
|
<th className="px-6 py-4">Vencimento</th>
|
|
<th className="px-6 py-4 text-right">Valor</th>
|
|
<th className="px-6 py-4 text-center">Estado</th>
|
|
<th className="px-6 py-4 text-center">Ações</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100 dark:divide-dark-border">
|
|
{faturas.filter(f => f.moradorId === currentUserId).length === 0 ? (
|
|
<tr>
|
|
<td colSpan="6" className="px-6 py-8 text-center text-slate-500 dark:text-slate-400">
|
|
Nenhuma fatura encontrada.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
faturas.filter(f => f.moradorId === currentUserId).map((fatura) => (
|
|
<tr key={fatura.id} className="hover:bg-slate-50 dark:hover:bg-dark-bg transition-colors">
|
|
<td className="px-6 py-4 text-slate-600 dark:text-slate-400">{fatura.dataEmissao}</td>
|
|
<td className="px-6 py-4 font-medium text-slate-800 dark:text-white">{fatura.categoria}</td>
|
|
<td className="px-6 py-4 text-slate-600 dark:text-slate-400">{fatura.dataVencimento}</td>
|
|
<td className="px-6 py-4 text-right font-medium text-slate-800 dark:text-white">{Number(fatura.valor).toFixed(2)}€</td>
|
|
<td className="px-6 py-4 text-center"><Badge status={fatura.status} /></td>
|
|
<td className="px-6 py-4 text-center">
|
|
{fatura.status === 'Pendente' ? (
|
|
<button
|
|
onClick={() => handlePayFatura(fatura)}
|
|
className="bg-blue-600 text-white px-4 py-1.5 rounded-lg text-xs font-bold hover:bg-blue-700 transition-colors shadow-sm"
|
|
>
|
|
Pagar
|
|
</button>
|
|
) : (
|
|
<span className="text-slate-400 text-xs font-bold flex items-center justify-center gap-1">
|
|
<CheckCircle size={14} /> Pago
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* --- FINANCES --- */}
|
|
{/* --- FINANCES --- */}
|
|
{activeTab === 'finance' && (
|
|
<div className="space-y-6 animate-fade-in h-full flex flex-col">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
<div className="bg-white dark:bg-dark-surface p-6 rounded-xl shadow-sm border border-slate-100 dark:border-dark-border border-l-4 border-l-green-500 relative overflow-hidden transition-colors">
|
|
<div className="relative z-10">
|
|
<h4 className="text-slate-500 dark:text-dark-mute text-xs font-bold uppercase tracking-wider">Receitas (Global)</h4>
|
|
<p className="text-2xl font-bold text-slate-800 dark:text-white mt-1">{totalIncome.toFixed(2)}€</p>
|
|
</div>
|
|
<TrendingUp className="absolute right-4 bottom-4 text-green-100 dark:text-green-900/20" size={64} />
|
|
</div>
|
|
<div className="bg-white dark:bg-dark-surface p-6 rounded-xl shadow-sm border border-slate-100 dark:border-dark-border border-l-4 border-l-red-500 relative overflow-hidden transition-colors">
|
|
<div className="relative z-10">
|
|
<h4 className="text-slate-500 dark:text-dark-mute text-xs font-bold uppercase tracking-wider">Despesas (Global)</h4>
|
|
<p className="text-2xl font-bold text-slate-800 dark:text-white mt-1">{totalExpense.toFixed(2)}€</p>
|
|
</div>
|
|
<TrendingDown className="absolute right-4 bottom-4 text-red-100 dark:text-red-900/20" size={64} />
|
|
</div>
|
|
<div className="bg-white dark:bg-dark-surface p-6 rounded-xl shadow-sm border border-slate-100 dark:border-dark-border border-l-4 border-l-blue-500 relative overflow-hidden transition-colors">
|
|
<div className="relative z-10">
|
|
<h4 className="text-slate-500 dark:text-dark-mute text-xs font-bold uppercase tracking-wider">Balanço Líquido</h4>
|
|
<p className={`text-2xl font-bold mt-1 ${balance >= 0 ? 'text-blue-600 dark:text-blue-400' : 'text-red-600 dark:text-red-400'}`}>
|
|
{balance > 0 ? '+' : ''}{balance.toFixed(2)}€
|
|
</p>
|
|
</div>
|
|
<Wallet className="absolute right-4 bottom-4 text-blue-100 dark:text-blue-900/20" size={64} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-sm border border-slate-100 dark:border-dark-border overflow-hidden flex-1 flex flex-col transition-colors">
|
|
<div className="p-6 border-b border-slate-100 dark:border-dark-border flex justify-between items-center">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-bold text-lg text-slate-800 dark:text-white">Diário Financeiro</h3>
|
|
<span className="text-xs bg-slate-100 dark:bg-dark-bg text-slate-600 dark:text-slate-400 px-2 py-1 rounded-full">{finances.length} movimentos</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{finances.length === 0 && (
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
for (const item of INITIAL_FINANCES) {
|
|
await set(push(ref(db, 'financas')), item);
|
|
}
|
|
showNotification("Dados de exemplo restaurados com sucesso!", "success");
|
|
} catch (error) {
|
|
console.error("Erro ao restaurar:", error);
|
|
showNotification("Erro ao restaurar.", "error");
|
|
}
|
|
}}
|
|
className="bg-orange-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-orange-600 transition-colors shadow-sm flex items-center gap-2"
|
|
>
|
|
<Wrench size={16} /> Restaurar Base de Dados
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => handleOpenModal('finance')}
|
|
className="bg-slate-900 dark:bg-slate-700 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-slate-800 dark:hover:bg-slate-600 flex items-center gap-2 shadow-lg hover:shadow-xl transition-all"
|
|
>
|
|
<Plus size={18} /> Novo Registo
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="overflow-auto flex-1">
|
|
<table className="w-full text-sm text-left">
|
|
<thead className="bg-slate-50 dark:bg-dark-bg text-slate-500 dark:text-slate-400 font-medium border-b border-slate-100 dark:border-dark-border sticky top-0 z-10">
|
|
<tr>
|
|
<th className="px-6 py-4">Data</th>
|
|
<th className="px-6 py-4">Categoria</th>
|
|
<th className="px-6 py-4">Descrição</th>
|
|
<th className="px-6 py-4">Tipo</th>
|
|
<th className="px-6 py-4 text-right">Valor</th>
|
|
<th className="px-6 py-4 text-center">Recibo</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100 dark:divide-dark-border">
|
|
{finances.map((item) => (
|
|
<tr key={item.id} className="hover:bg-slate-50 dark:hover:bg-dark-bg transition-colors">
|
|
<td className="px-6 py-4 text-slate-600 dark:text-slate-400 font-mono">{item.date}</td>
|
|
<td className="px-6 py-4 font-bold text-slate-700 dark:text-slate-200">{item.category}</td>
|
|
<td className="px-6 py-4 text-slate-500 dark:text-dark-mute">{item.desc}</td>
|
|
<td className="px-6 py-4">
|
|
<Badge status={item.type === 'income' ? 'Receita' : 'Despesa'} />
|
|
</td>
|
|
<td className={`px-6 py-4 text-right font-bold font-mono ${item.type === 'income' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
|
{item.type === 'income' ? '+' : '-'}{Number(item.amount).toFixed(2)}€
|
|
</td>
|
|
<td className="px-6 py-4 text-center">
|
|
<button onClick={() => {
|
|
showNotification(`Recibo de ${item.category} descarregado.`, 'success');
|
|
sendSystemNotification(`Recibo de ${item.category} descarregado.`, 'info', currentUserId);
|
|
}} className="text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors" title="Descarregar Recibo">
|
|
<FileText size={16} />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* --- MESSAGES --- */}
|
|
{activeTab === 'messages' && (
|
|
<div className="bg-white dark:bg-dark-surface rounded-xl shadow-sm border border-slate-100 dark:border-dark-border overflow-hidden animate-fade-in flex flex-col h-full transition-colors md:flex-row min-h-[500px]">
|
|
{/* Contact List */}
|
|
<div className="w-full md:w-1/3 border-r border-slate-100 dark:border-dark-border flex flex-col h-full">
|
|
<div className="p-4 border-b border-slate-100 dark:border-dark-border flex justify-between items-center bg-slate-50 dark:bg-dark-bg">
|
|
<h3 className="font-bold text-slate-800 dark:text-white">Conversas</h3>
|
|
<button onClick={() => { setIsCreateGroupModalOpen(true); setNewGroupName(''); setNewGroupMembers([]); }} className="p-2 bg-blue-100 text-blue-600 rounded-lg hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-400 transition-colors" title="Criar Grupo">
|
|
<Plus size={18} />
|
|
</button>
|
|
</div>
|
|
<div className="p-3 border-b border-slate-100 dark:border-dark-border">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
|
<input type="text" placeholder="Procurar mensagens..." className="w-full pl-9 pr-3 py-2 bg-slate-100 dark:bg-dark-card border-none rounded-lg text-sm focus:ring-2 focus:ring-blue-500 dark:text-white" />
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div
|
|
onClick={() => setActiveChat({ type: 'global', id: 'global', name: 'Fórum do Condomínio' })}
|
|
className={`p-3 border-b-2 cursor-pointer transition-colors ${activeChat.type === 'global' ? 'border-blue-100 dark:border-blue-900/30 bg-blue-50/50 dark:bg-blue-900/10 hover:bg-blue-50/80 dark:hover:bg-blue-900/20' : 'border-transparent hover:bg-slate-50 dark:hover:bg-dark-card'}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-blue-600 dark:text-blue-400 font-bold shrink-0">
|
|
<Users size={20} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex justify-between items-baseline mb-0.5">
|
|
<h4 className="text-sm font-bold text-slate-800 dark:text-slate-200 truncate">Fórum do Condomínio</h4>
|
|
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium whitespace-nowrap">Geral</span>
|
|
</div>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">Grupo partilhado</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{chatGroups.filter(g => g.members && (Object.values(g.members).map(String).includes(String(currentUserId)) || userRole === 'admin')).map(group => (
|
|
<div
|
|
key={group.id}
|
|
onClick={() => 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'}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center text-purple-600 dark:text-purple-400 font-bold shrink-0 text-sm">
|
|
<Users size={16} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex justify-between items-baseline mb-0.5">
|
|
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 truncate">{group.name}</h4>
|
|
{activeChat.id === group.id && <span className="w-2 h-2 rounded-full bg-blue-500"></span>}
|
|
</div>
|
|
<p className="text-xs text-slate-500 dark:text-slate-500 truncate">Grupo</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{residents.filter(r => r.id !== currentUserId).map(res => (
|
|
<div
|
|
key={res.id}
|
|
onClick={() => setActiveChat({ type: 'private', id: res.id, name: res.name })}
|
|
className={`p-3 border-b border-slate-50 dark:border-dark-border/50 cursor-pointer transition-colors ${activeChat.id === res.id ? 'bg-blue-50/50 dark:bg-blue-900/10' : 'hover:bg-slate-50 dark:hover:bg-dark-card'}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-600 dark:text-slate-300 font-bold shrink-0 text-sm">
|
|
{res.name.substring(0, 2).toUpperCase()}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex justify-between items-baseline mb-0.5">
|
|
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 truncate">{res.name} {res.unit && `(${res.unit})`}</h4>
|
|
{activeChat.id === res.id && <span className="w-2 h-2 rounded-full bg-blue-500"></span>}
|
|
</div>
|
|
<p className="text-xs text-slate-500 dark:text-slate-500 truncate">Morador</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chat Area */}
|
|
<div className="flex-1 flex flex-col h-full bg-slate-50/50 dark:bg-dark-bg/50">
|
|
<div className="p-4 border-b border-slate-100 dark:border-dark-border bg-white dark:bg-dark-surface flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold ${activeChat.type === 'global' ? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400' : activeChat.type === 'group' ? 'bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-400' : 'bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-300'}`}>
|
|
{activeChat.type === 'global' || activeChat.type === 'group' ? <Users size={20} /> : activeChat.name.substring(0, 2).toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<h3 className="font-bold text-slate-800 dark:text-white">{activeChat.name}</h3>
|
|
<p className="text-xs text-green-500 font-medium">{activeChat.type === 'global' ? 'Todos os moradores' : activeChat.type === 'group' ? 'Grupo Privado' : 'Privado'}</p>
|
|
</div>
|
|
</div>
|
|
<button className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200"><Info size={20}/></button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 flex flex-col">
|
|
<div className="text-center my-4"><span className="text-xs bg-slate-200/50 dark:bg-slate-800 text-slate-500 px-3 py-1 rounded-full">Mensagens</span></div>
|
|
|
|
{messages.map((msg) => {
|
|
const isMe = msg.senderId === currentUserId;
|
|
const timeString = new Date(msg.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
|
|
|
return (
|
|
<div key={msg.id} className={`flex ${isMe ? 'justify-end' : 'justify-start'}`}>
|
|
<div className={`${isMe ? 'bg-blue-600 text-white rounded-2xl rounded-tr-sm' : 'bg-white dark:bg-dark-card border border-slate-100 dark:border-slate-800 text-slate-700 dark:text-slate-200 rounded-2xl rounded-tl-sm'} p-3 max-w-[80%] shadow-sm`}>
|
|
{!isMe && (
|
|
<p className={`text-xs ${msg.role === 'admin' ? 'text-blue-600 dark:text-blue-400' : 'text-orange-600 dark:text-orange-400'} font-bold mb-1`}>
|
|
{msg.senderName}
|
|
</p>
|
|
)}
|
|
<p className="text-sm">{msg.text}</p>
|
|
<span className={`text-[10px] ${isMe ? 'text-blue-200' : 'text-slate-400'} block text-right mt-1`}>{timeString}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="p-4 bg-white dark:bg-dark-surface border-t border-slate-100 dark:border-dark-border">
|
|
<form onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
if (!newMessageText.trim()) return;
|
|
|
|
try {
|
|
const path = activeChat.type === 'global'
|
|
? 'mural_mensagens'
|
|
: activeChat.type === 'group'
|
|
? `mensagens_grupo/${activeChat.id}`
|
|
: `mensagens_privadas/${[currentUserId, activeChat.id].sort().join('_')}`;
|
|
|
|
const newMsgRef = push(ref(db, path));
|
|
await set(newMsgRef, {
|
|
text: newMessageText,
|
|
senderId: currentUserId,
|
|
senderName: currentUserName,
|
|
role: userRole,
|
|
timestamp: Date.now()
|
|
});
|
|
setNewMessageText('');
|
|
} catch (error) {
|
|
console.error("Erro ao enviar mensagem:", error);
|
|
showNotification("Erro ao enviar mensagem.", "error");
|
|
}
|
|
}} className="flex gap-2">
|
|
<button type="button" className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors hidden sm:block">
|
|
<Paperclip size={20} />
|
|
</button>
|
|
<input
|
|
type="text"
|
|
value={newMessageText}
|
|
onChange={(e) => setNewMessageText(e.target.value)}
|
|
placeholder="Escreva a sua mensagem..."
|
|
className="flex-1 bg-slate-50 dark:bg-dark-bg border border-slate-200 dark:border-dark-border rounded-full px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:text-white"
|
|
/>
|
|
<button type="submit" disabled={!newMessageText.trim()} className="p-2 bg-blue-600 text-white rounded-full hover:bg-blue-700 disabled:bg-blue-400 transition-colors shadow-sm flex items-center justify-center shrink-0 w-10 h-10">
|
|
<Send size={18} />
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* --- MAINTENANCE --- */}
|
|
{activeTab === 'maintenance' && <MaintenanceView />}
|
|
|
|
{/* --- PROFILE --- */}
|
|
{activeTab === 'profile' && <ProfileView theme={theme} setTheme={setTheme} />}
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{/* --- Toast Notification --- */}
|
|
{notification && (
|
|
<div className={`fixed bottom-6 right-6 px-6 py-4 rounded-xl shadow-2xl flex items-center gap-3 animate-slide-up z-50 text-white ${notification.type === 'error' ? 'bg-red-600' : notification.type === 'warning' ? 'bg-orange-600' : 'bg-slate-800'}`}>
|
|
{notification.type === 'success' ? <CheckCircle size={20} className="text-green-400" /> : <AlertCircle size={20} />}
|
|
<span className="font-medium">{notification.message}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* --- Modals --- */}
|
|
|
|
{/* Resident Modal */}
|
|
<Modal isOpen={activeModal === 'resident'} onClose={handleCloseModal} title={editingItem ? "Editar Condómino" : "Novo Condómino"}>
|
|
<form onSubmit={handleSaveResident}>
|
|
<InputGroup label="Fração" name="unit" value={formData.unit || ''} onChange={handleInputChange} placeholder="Ex: 1º Esq" required />
|
|
<InputGroup label="Nome Completo" name="name" value={formData.name || ''} onChange={handleInputChange} placeholder="Nome do proprietário" required />
|
|
<InputGroup label="Email" type="email" name="email" value={formData.email || ''} onChange={handleInputChange} placeholder="email@exemplo.com" />
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<InputGroup label="Telemóvel" name="contact" value={formData.contact || ''} onChange={handleInputChange} placeholder="912 345 678" required />
|
|
<InputGroup label="Palavra-passe" type="text" name="password" value={formData.password || ''} onChange={handleInputChange} placeholder={editingItem ? "Deixar em branco para manter" : "1234"} required={!editingItem} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<InputGroup label="Estado" name="status" value={formData.status || 'Pago'} onChange={handleInputChange} options={[{ value: 'Pago', label: 'Pago' }, { value: 'Pendente', label: 'Pendente' }, { value: 'Atrasado', label: 'Atrasado' }]} />
|
|
<InputGroup label="Valor Pendente (€)" type="number" name="pending" value={formData.pending || 0} onChange={handleInputChange} />
|
|
</div>
|
|
<button type="submit" className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg mt-4 flex justify-center gap-2">
|
|
<Save size={20} /> Guardar Condómino
|
|
</button>
|
|
</form>
|
|
</Modal>
|
|
|
|
{/* Emitir Fatura Modal */}
|
|
<Modal isOpen={activeModal === 'emitir_fatura'} onClose={handleCloseModal} title="Emitir Nova Fatura">
|
|
<form onSubmit={handleSaveFatura}>
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Condómino</label>
|
|
<select
|
|
name="moradorId"
|
|
value={formData.moradorId || ''}
|
|
onChange={handleInputChange}
|
|
className="w-full px-4 py-2 border border-slate-300 dark:border-dark-border rounded-lg bg-white dark:bg-dark-card text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
|
required
|
|
>
|
|
<option value="">Selecione um morador...</option>
|
|
{residents.map(r => (
|
|
<option key={r.id} value={r.id}>{r.unit} - {r.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<InputGroup label="Categoria" name="categoria" value={formData.categoria || 'Quotas'} onChange={handleInputChange} options={[{value: 'Quotas', label: 'Quotas'}, {value: 'Luz', label: 'Luz'}, {value: 'Água', label: 'Água'}, {value: 'Aluguer', label: 'Aluguer'}, {value: 'Manutenção', label: 'Manutenção'}, {value: 'Outros', label: 'Outros'}]} />
|
|
<InputGroup label="Valor (€)" type="number" name="valor" value={formData.valor || ''} onChange={handleInputChange} placeholder="0.00" required />
|
|
</div>
|
|
<InputGroup label="Data Vencimento" type="date" name="dataVencimento" value={formData.dataVencimento || ''} onChange={handleInputChange} required />
|
|
<button type="submit" className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg mt-4 flex justify-center gap-2 transition-colors">
|
|
<FileText size={20} /> Emitir Fatura
|
|
</button>
|
|
</form>
|
|
</Modal>
|
|
|
|
{/* Finance Modal */}
|
|
<Modal isOpen={activeModal === 'finance'} onClose={handleCloseModal} title="Registar Movimento Financeiro">
|
|
<form onSubmit={handleSaveFinance}>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<InputGroup label="Tipo" name="type" value={formData.type || 'expense'} onChange={handleInputChange} options={[{ value: 'income', label: 'Receita (+)' }, { value: 'expense', label: 'Despesa (-)' }]} />
|
|
<InputGroup label="Data" type="date" name="date" value={formData.date || ''} onChange={handleInputChange} required />
|
|
</div>
|
|
<InputGroup label="Categoria" name="category" value={formData.category || ''} onChange={handleInputChange} placeholder="Ex: Limpeza, Elevadores, Quotas..." required />
|
|
<InputGroup label="Valor (€)" type="number" name="amount" value={formData.amount || ''} onChange={handleInputChange} placeholder="0.00" required />
|
|
<InputGroup label="Descrição" name="desc" value={formData.desc || ''} onChange={handleInputChange} placeholder="Detalhes do movimento" />
|
|
<button type="submit" className="w-full bg-slate-800 hover:bg-slate-900 dark:bg-slate-700 dark:hover:bg-slate-600 text-white font-bold py-3 rounded-lg mt-4 flex justify-center gap-2 transition-colors">
|
|
<Save size={20} /> Registar Movimento
|
|
</button>
|
|
</form>
|
|
</Modal>
|
|
|
|
{/* Issue Modal */}
|
|
<Modal isOpen={activeModal === 'issue'} onClose={handleCloseModal} title="Reportar Ocorrência">
|
|
<form onSubmit={handleSaveIssue}>
|
|
<InputGroup label="Título do Problema" name="title" value={formData.title || ''} onChange={handleInputChange} placeholder="Ex: Lâmpada fundida" required />
|
|
<InputGroup label="Localização" name="location" value={formData.location || ''} onChange={handleInputChange} placeholder="Ex: Hall de entrada" required />
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<InputGroup label="Prioridade" name="priority" value={formData.priority || 'Média'} onChange={handleInputChange} options={[{ value: 'Baixa', label: 'Baixa' }, { value: 'Média', label: 'Média' }, { value: 'Alta', label: 'Alta' }]} />
|
|
<InputGroup label="Data" type="date" name="date" value={formData.date || ''} onChange={handleInputChange} />
|
|
</div>
|
|
<button type="submit" className="w-full bg-orange-600 hover:bg-orange-700 text-white font-bold py-3 rounded-lg mt-4 flex justify-center gap-2">
|
|
<Save size={20} /> Reportar
|
|
</button>
|
|
</form>
|
|
</Modal>
|
|
|
|
{/* Booking Modal */}
|
|
<Modal isOpen={activeModal === 'booking'} onClose={handleCloseModal} title="Nova Reserva">
|
|
<form onSubmit={handleSaveBooking}>
|
|
<InputGroup label="Espaço" name="facility" value={formData.facility || 'gym'} onChange={handleInputChange} options={[{ value: 'gym', label: 'Ginásio' }, { value: 'hall', label: 'Salão de Festas' }, { value: 'park', label: 'Parque de Jogos' }]} />
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<InputGroup label="Data" type="date" name="date" value={formData.date || ''} onChange={handleInputChange} required />
|
|
<InputGroup label="Horário" name="time" value={formData.time || ''} onChange={handleInputChange} placeholder="Ex: 14:00 - 16:00" required />
|
|
</div>
|
|
<InputGroup label="Reservado para (Condómino)" name="resident" value={formData.resident || ''} onChange={handleInputChange} placeholder="Nome do residente" required disabled={userRole !== 'admin'} />
|
|
|
|
<div className="bg-slate-50 dark:bg-dark-card p-4 rounded-lg border border-slate-200 dark:border-dark-border mt-2 mb-4 flex justify-between items-center transition-colors">
|
|
<span className="text-sm font-medium text-slate-600 dark:text-slate-300">Custo Estimado:</span>
|
|
<span className="text-xl font-bold text-slate-800 dark:text-white">{formData.cost || 0}€</span>
|
|
</div>
|
|
|
|
<button type="submit" className="w-full bg-purple-600 hover:bg-purple-700 text-white font-bold py-3 rounded-lg flex justify-center gap-2">
|
|
<CheckCircle size={20} /> Confirmar Reserva
|
|
</button>
|
|
</form>
|
|
</Modal>
|
|
|
|
<Modal isOpen={isCreateGroupModalOpen} onClose={() => setIsCreateGroupModalOpen(false)} title="Criar Novo Grupo">
|
|
<form onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
if (!newGroupName.trim() || newGroupMembers.length === 0) {
|
|
showNotification('Selecione um nome e pelo menos um membro.', 'warning');
|
|
return;
|
|
}
|
|
try {
|
|
const groupId = 'grupo_' + Date.now();
|
|
const allMembers = [...newGroupMembers, currentUserId];
|
|
await set(ref(db, `grupos_chat/${groupId}`), {
|
|
id: groupId,
|
|
name: newGroupName,
|
|
members: allMembers,
|
|
createdBy: currentUserId,
|
|
timestamp: Date.now()
|
|
});
|
|
setIsCreateGroupModalOpen(false);
|
|
setActiveChat({ type: 'group', id: groupId, name: newGroupName });
|
|
showNotification('Grupo criado com sucesso.', 'success');
|
|
} catch (err) {
|
|
showNotification('Erro ao criar grupo.', 'error');
|
|
}
|
|
}}>
|
|
<InputGroup label="Nome do Grupo" name="newGroupName" value={newGroupName} onChange={(e) => setNewGroupName(e.target.value)} required />
|
|
|
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1 mt-4">Selecionar Moradores</label>
|
|
<div className="max-h-48 overflow-y-auto border border-slate-300 dark:border-dark-border rounded-lg bg-white dark:bg-dark-card p-2">
|
|
{residents.filter(r => r.id !== currentUserId).map(r => (
|
|
<div key={r.id} className="flex items-center gap-2 p-2 hover:bg-slate-50 dark:hover:bg-slate-800 rounded">
|
|
<input type="checkbox" id={`chk_${r.id}`} checked={newGroupMembers.includes(r.id)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) setNewGroupMembers([...newGroupMembers, r.id]);
|
|
else setNewGroupMembers(newGroupMembers.filter(id => id !== r.id));
|
|
}}
|
|
className="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
|
|
/>
|
|
<label htmlFor={`chk_${r.id}`} className="text-sm text-slate-700 dark:text-slate-300 cursor-pointer select-none">{r.name} {r.unit ? `(${r.unit})` : ''}</label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button type="submit" className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg flex justify-center gap-2 mt-4 transition-colors">
|
|
<CheckCircle size={20} /> Criar Grupo
|
|
</button>
|
|
</form>
|
|
</Modal>
|
|
<Modal isOpen={confirmDialog.isOpen} onClose={() => setConfirmDialog(prev => ({ ...prev, isOpen: false }))} title="Confirmação">
|
|
<div className="p-2">
|
|
<p className="text-slate-700 dark:text-slate-300 text-base mb-6 text-center font-medium">{confirmDialog.message}</p>
|
|
<div className="flex gap-3 justify-center">
|
|
<button
|
|
className="px-6 py-2 bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-800 dark:text-white rounded-lg transition-colors font-medium"
|
|
onClick={() => setConfirmDialog(prev => ({ ...prev, isOpen: false }))}
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium shadow-sm"
|
|
onClick={() => {
|
|
setConfirmDialog(prev => ({ ...prev, isOpen: false }));
|
|
if (confirmDialog.onConfirm) confirmDialog.onConfirm();
|
|
}}
|
|
>
|
|
Confirmar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const root = createRoot(document.getElementById('root'));
|
|
root.render(
|
|
<ErrorBoundary>
|
|
<App />
|
|
</ErrorBoundary>
|
|
);
|
|
</script>
|
|
<div id="google_translate_element"></div>
|
|
<!-- Firebase configs moved to top in React Module -->
|
|
</body>
|
|
</html> |