This commit is contained in:
2026-05-11 17:19:48 +01:00
parent c16fda3fe0
commit ce3e22e39b
3 changed files with 578 additions and 118 deletions

View File

@@ -9,15 +9,15 @@ diff.txt,1778227426505,5c43e21897b2247e203b29b2a1322bb7e4e24ffb53ffa8c233ced1a00
RELATORIO_TECNICO.md,1778058174796,fad35f12b1f2d062f72e7a448bb643fd3cfdd24423eaacc14cbbb20172ead7be
README.md,1778057411525,cc32fa073114e014f06988652ebe4c6af9ba3dbf913b9f25d38a16ce3d700605
.vscode/settings.json,1778056161665,3a247752ccf28f259e2e604bf44311ab91b6db3864b120e08f2823951d1c55d8
.git/index,1778142319280,ab76dcf6508b9c5d11367962c2914ffa97326380fae04b0bc644306e2fd2f52f
.git/index,1778233262846,3d97d152b6cd66313163d0320e64794682d3fee811f0336d8b49d506d3f867a4
.git/description,1773160274654,3cdc7b6a29de07f63b76d16b9911d93468000346945f759d4f6456660b5c113b
.git/config,1773914677241,cb4cd1c7c28ac13d2dccf79f667245402cf551c998ba1f6b58abc28f3ae11e7f
.git/ORIG_HEAD,1777392979044,2612c449de4f930bfb197ddb13780ca77cbb7bf6db7e91493d412b634ddcdebd
.git/HEAD,1773160809135,a39dc51e21d1523cdef2091e7c7ab30a33ad42a7cd5da1f45139746e5c24b667
.git/COMMIT_EDITMSG,1778142319280,6df556362d9eb5101e618d025d58a67cca69a740780977ebdaf2579be85ce022
.git/refs/remotes/origin/main,1778142319634,36078041c052a72d91fd289dcd7f4ec8a7f2ca2a77582b9a53ab10fa0789fbe4
.git/COMMIT_EDITMSG,1778233262846,41c29203575dc78117a18c63a35900560c3beac36c324fc4df70167abc0b3019
.git/refs/remotes/origin/main,1778233263214,4e12a5ab5d391dd0a2dd6df79a94de3b2f5c3f748fa0eb035c5a84ac71380687
.git/refs/remotes/origin/HEAD,1773830493701,0f5d56efe56c5dcabb387d965aad58d0f60a3b7485cb9b04bef04b93bebf911e
.git/refs/heads/main,1778142319281,36078041c052a72d91fd289dcd7f4ec8a7f2ca2a77582b9a53ab10fa0789fbe4
.git/refs/heads/main,1778233262847,4e12a5ab5d391dd0a2dd6df79a94de3b2f5c3f748fa0eb035c5a84ac71380687
.git/objects/fd/3d3838a9118dc446e6ab65d38a5ce1747fd0d6,1777393032091,b7fcc251a3edcfc6eb1d7a0ea70a10d16f297520eb5e95c822ab5f754da8dc4a
.git/objects/f7/30f952945d24a8d4137d1c591d7477ee96c1bd,1778142319279,13078d7dc1d56d7c137032550b76a68b209b664f988963a49b18b4d6226fe3c0
.git/objects/f6/b7a98471bb9c718c8091a62fa65243aceb92b7,1778067817140,12c318e32181abf048c4811d48552676bff899b5172e58d11fd9136ae98c2c71
@@ -27,13 +27,18 @@ README.md,1778057411525,cc32fa073114e014f06988652ebe4c6af9ba3dbf913b9f25d38a16ce
.git/objects/f3/7d7211c51f051db56b0e67a1bfca55f649e1e5,1773161195360,a0961540cd8d800dd424b0eb7d503bb35012ad90477c1ec10b9e4b34e1102027
.git/objects/ed/cc9b9ffb9c172f4579f71d3e10414f58ea26d0,1777997762125,439ee4c3ff19456eafa85052089df8c4ea4c00c1c293fd4c639377d8d125ebb9
.git/objects/ea/2dee2f517449595d633889a410cc2aefa2b513,1776937200147,9392a6f5dd0687166fd9b60a4f28176604046d293196fe53038e9d2e456d5336
.git/objects/e9/78ed3d2aa5d35e562ff0b8b62be183757e7147,1778233262824,9f94195a7eb18a88ead30e511d59ef339ebd937f93c28788c2eec6ea54ab82c3
.git/objects/e8/5b891f05021af7f78059851bac361ba110e459,1777903218123,c0684017afd5d4cb4234a4191773d088b5ae747e88b477cabcd08fff53e24450
.git/objects/e7/1629b1f461748a0642a397d9aa94d4425d3a6a,1777997762126,7511a853da08dcde2f169bc9afe5b92eb9028dc27052c95a436b62368897e4ad
.git/objects/e0/f98cddb7f08a69d1505d65f6f09df732892255,1778233262831,8fe3c3808325c72d51480bb2208b547350919e47e38265ae7febab343331f2dc
.git/objects/e0/6cdd4ddfacadcef9577916c84406559b985623,1773161195362,d89c95c83f040e788bd182a67bc3cbb9afdf7d3740a388cc4d43d2514b80e556
.git/objects/da/444cce2c8426f321e774e9bc8134642bae50ae,1773830457778,293cb0fb7b39d4ffbece90d8f57bcd730c29d754a284c7d83436a489661e507e
.git/objects/d8/ad43562f86956e101ce3c351a509b411b3f02c,1778233262827,9624a603cfd390b390eea159ff57d9c59c729c8d16a0f58967b9e923fa01225a
.git/objects/d3/f58873ab4f70d24d92349c7e85c7df5c7bb7c2,1776937200165,ca8deaee745fd2e87b4804183acc8c484a36b88db729b2c4cb52f17f99ffb747
.git/objects/d0/ca0c1d5c7b38b5a5ec040918ce8ece33dfe52c,1776937200149,9d98989471faf7b255a3da69495d5ff5cfe01302e1626558a570e33a74b8c716
.git/objects/cc/46b618bf0eacaf883f41f13760dee9a3e2e408,1778233262830,73b67ba6a9f36e7e1d5328ea4227caa4c18721f962fc0b02cf1a2a2a6ca66838
.git/objects/c2/51549f632caf9449f632743316c3cf728fffc8,1776937257099,1f81e188648ae90999de0d723593b312ae205c183aa0bfd8562ffc83742a85ab
.git/objects/c1/6fda3fe042e26ed584698bee899b4891224311,1778233262846,980353eb83917bde2933913ef1ea9436872ebc11e5fe2944b4c95bb0c70b0a6a
.git/objects/b2/d3930d99b8f06b5d0ae445cc28cd2a4af0da24,1778067817138,e9d22b7f6ef7f12457135884486154e22b6c283f056db3968907d4585fda8ec2
.git/objects/b1/23a037ff8edd8fe29dc828726a53f9b55dcc31,1773161195361,c118b3ce9a20c3b4aa540144f17e7dcc1bc26ff0b576c6e00451941e200feee9
.git/objects/aa/64354c06769190bc3e113bd7ee4dfb0bfcdad1,1773830457796,31833bc03904dec3c2a34a49028c56042ee348df266b859968414bc009c83f7f
@@ -41,12 +46,14 @@ README.md,1778057411525,cc32fa073114e014f06988652ebe4c6af9ba3dbf913b9f25d38a16ce
.git/objects/a5/c46d963a0c127d421044eb6e50f1b3c8271d95,1777903218134,32190169472d3bc7ae8b307521f604f6fcdeed486694f0deffa2e9e55dc72e9a
.git/objects/a0/de46ff5465329041fcd659cf66cadca4415824,1776937200146,43fc08e82b2fabcf91b8670535d21d353a189c5cbd6fe234369b05fead5c8e5e
.git/objects/9e/71203cb36f10248f9fa89f1d11f8335ed55be2,1773830457795,c6ee24879edd639035190699f85cf3cdfeb1a8926218f94b9152470d9e75e25d
.git/objects/9b/eb4e72fcd14eb3f87c7ed1d2f0757787613002,1778233262845,4a09fd3e0d0afc777c0625f5036206007a7d8ed5722811b34f9c4b7ddeff5b25
.git/objects/99/8d174cd16af43e7684399aec34ac75fc6eb7f4,1776326595547,a9a6302756f4d83011553a94d7518d7162818c535e13f15f94868127fd516691
.git/objects/94/5d8710c7134fdf4feaa701af8d88bda61db300,1776957069564,b68f1087c81f6a22ae676c795169f34674865c85b4e9631b82db50a73cb190d5
.git/objects/92/d9d123244957aa1b1dd1a6f54b9899ec28def8,1773161195399,3f3e3c1f4e739595d33d89aef665c3933ec1af7914020cf0ff6de3e3d41d0109
.git/objects/8e/7980160dd6e37a0edf181cb022b86334e205fd,1777393032077,921856f2b98e34016c387ef1f73025bba7ed846c64d8017ec6d2e6e0e9f7c586
.git/objects/8d/053f517afe910c5933a43eca4e7dad2fe5edf1,1777997762122,e3802ac195f07bda67d59b5754f2254282916e9d5277388af9751fc1cb511723
.git/objects/8c/b6ffc314647094561a18f0af805412260abc3b,1776326595546,a256a28d0e28ac76431ae5406923461a7b4fd7aaac40f5dae947377abc2ca830
.git/objects/85/6204bc356b1ab244d22d75fde1bfd851573ed2,1778233262827,2ff5ca7847203ee855ebd8b080fe025583f079e2abf71c524a163802624f26a0
.git/objects/85/245c9ed8e19d706e7d22b06854421666354793,1778067817117,679bc4918add3e07552006ecbe2efa9f8219b4096728e08a6932cfd36cf3bbb0
.git/objects/84/2dd08f73738644fe58eecb5409d5ebd1544efb,1776326595582,b5b51d6c09e2f4e9767489040755ab4e1c4cdce30f46567e4391e0e228f04395
.git/objects/7f/0d5ab4d6ff0318cbd6a9c3f8eb57aaac4634a4,1777542176251,16d9b4319d629d55eb92c57e1070cca1671b642d2c780895fc600570fcb5b895
@@ -90,6 +97,7 @@ README.md,1778057411525,cc32fa073114e014f06988652ebe4c6af9ba3dbf913b9f25d38a16ce
.git/objects/23/cec1dc9936a6d9be11078377c6a7685b26373f,1776183305295,d709b165a1199d66da18ba51afe1471aa7b068d4066284bf28d333f09e0c9670
.git/objects/1f/96a9bfae61d4e97fc9f0375975e89f031ce971,1777542176253,10e365b22153e0f123b7086751a36b6779ce2368f721a3edffed8be8b92c4dba
.git/objects/1e/df059fdd108ea56915dfccc9a58ad4170d580b,1778067817119,dcaad430c4d88918e61ac956c09211ed40896ae36b5285de662d2cc13db50355
.git/objects/1e/1df6e1feeda8628b8ba26de95eebaa19b019dd,1778233262845,9adc4a73ac30db7f357cae08388d407d668a27c1640dbaa9ddaf2b46861db8e0
.git/objects/1b/95650abf8d99d02977de5d04e6dfe3e07b134a,1778142319279,a7e079c7dcf41cc8468bc5f8ce72c6501a952c6a0eb14dca080386f687b460b2
.git/objects/1a/fa1ed665e30605cae8462faf9d0fb8ad15bc18,1778142319265,82370cdcc2131fd6e09cb7ea2961c932342c6264cc1e345f27c43f1122390a08
.git/objects/1a/6f0a583386ccf1152060c78f3237e4b928ebab,1777903218122,3b445ff37e72f864c5ce690f3b9e417ddfaee3e33dca1ad27390fcdc5e8c8e8d
@@ -100,10 +108,10 @@ README.md,1778057411525,cc32fa073114e014f06988652ebe4c6af9ba3dbf913b9f25d38a16ce
.git/objects/0b/fc5efae1d163add058f1940feea6649f5da1b1,1777393032074,f19ecb9e3799eb6cd2f62e145b429f8c7c48fe8ffda704c49d6d925e9818c000
.git/objects/04/11accbb7c7608ce7cc7a296cdaaaf9c611fd28,1778067817122,106c0ea1a1d8ee8d73a0e48241240894ee4cd3269e40c191b8821a897d3a4a25
.git/objects/00/0c1cd721b99d14281c3724f5488b040c152515,1777997762142,7d131dd9c4c33d6e017428809d3b927f369ee8fa38c7d6ae79d01bc10561e22b
.git/logs/HEAD,1778142319281,4230a25743774efc5dc7eb3f93703717d798e891ecb21cb6cb27e9db7e322eae
.git/logs/refs/remotes/origin/main,1778142319635,0972562652fc6a13ebbfdea2f22a172e20a350c3ac55e979dc745b11499dc0ab
.git/logs/HEAD,1778233262847,773a20f4b758ec133fee18b4ad4676f06a1b4ec38ef42ebbc87e4b4cf3586feb
.git/logs/refs/remotes/origin/main,1778233263215,21f6bfeb5ec12772be4cec727816b90484b6efa600a0e5367327c750fd82ccde
.git/logs/refs/remotes/origin/HEAD,1773830493702,1eba2cff5035849e216a15d3b6013593fa5ef345a8d76bb2881d83b3cb247576
.git/logs/refs/heads/main,1778142319281,4230a25743774efc5dc7eb3f93703717d798e891ecb21cb6cb27e9db7e322eae
.git/logs/refs/heads/main,1778233262847,773a20f4b758ec133fee18b4ad4676f06a1b4ec38ef42ebbc87e4b4cf3586feb
.git/info/exclude,1773160274653,a362e375cc3330f10d115cfeb0f90a325219d80a764d57e2c4873f78d1d0b4f5
.git/hooks/update.sample,1773160274656,2b0a4f42fa30a128b46ad80e89c1f73b89d58b8abb9e92aee1c35625baccb584
.git/hooks/sendemail-validate.sample,1773160274654,4d0768bc11017be6b99d4bb4d34b4c8b2fd7ae8a93d42727591afb6737577db2
@@ -119,6 +127,6 @@ README.md,1778057411525,cc32fa073114e014f06988652ebe4c6af9ba3dbf913b9f25d38a16ce
.git/hooks/fsmonitor-watchman.sample,1773160274655,d366d691e33458260d77c44be36050a3faf0aa12760955cc8ca85ee88389c400
.git/hooks/commit-msg.sample,1773160274654,4df962ba3955944bec38b211351c73f083d7b0e5360a5d3d76a49548e7314f9e
.git/hooks/applypatch-msg.sample,1773160274655,91b94f5feaf0e4d2e6e7808a9188384a4300adf024fa24c48547ee87c64d6558
.git/FETCH_HEAD,1778233047619,6239b093503ca2be47debac6c929f0e9c95a70f288976e1db864811742a15cb9
temp_script.jsx,1778231961660,0300cccadb4be44eb475ffa5f873de59003c261b28cd6d7ad79a352903a30c2b
index.html,1778231918196,c7de50f6e2f8f93ae1479a7a3e62822f785c8a3b1334daf9f350a50da728d6de
.git/FETCH_HEAD,1778516078096,558029542eb68ba97a8b683052b7acd2d555f2341856002d3713806b561a3e3f
temp_script.jsx,1778515975081,55db2d141363815c1a6850325fffea1772f799d98a70d6ed650c83986adbac54
index.html,1778515901440,79fcfa64424023b80204064ff6127a1a110fb4e043526807f61fe931368780a1

View File

@@ -115,7 +115,7 @@
TrendingUp, TrendingDown, CheckCircle, AlertCircle, Clock, LogOut,
Edit2, Trash2, Save, Filter, MoreVertical, FileText,
Dumbbell, PartyPopper, Trophy, Map, Calendar, MapPin, Info,
MessageCircle, Paperclip, Send
MessageCircle, Paperclip, Send, Store, HeartPulse, Waves, ShoppingCart, Navigation, Car
} from 'lucide-react';
import { app } from './firebase.js';
@@ -649,6 +649,30 @@
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);
@@ -924,7 +948,10 @@
});
const newPending = (Number(morador.pending) || 0) + valor;
await set(ref(db, `condominos/${morador.id}/pending`), newPending);
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');
@@ -944,8 +971,11 @@
const morador = residents.find(r => r.id === fatura.moradorId);
if (morador) {
let newPending = (Number(morador.pending) || 0) - Number(fatura.valor);
if (newPending < 0) newPending = 0;
await set(ref(db, `condominos/${morador.id}/pending`), newPending);
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');
@@ -963,8 +993,11 @@
const morador = residents.find(r => r.id === fatura.moradorId);
if (morador) {
let newPending = (Number(morador.pending) || 0) - Number(fatura.valor);
if (newPending < 0) newPending = 0;
await set(ref(db, `condominos/${morador.id}/pending`), newPending);
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');
@@ -1178,64 +1211,257 @@
);
};
const MapView = () => (
const MapView = () => {
const [activePoint, setActivePoint] = useState(null);
const [espacos, setEspacos] = useState([]);
const [route, setRoute] = useState(null);
const [isLocating, setIsLocating] = useState(false);
const IconMap = { Building2, ShoppingCart, Store, HeartPulse, Info, MapPin, Trophy, Dumbbell, PartyPopper, Waves };
useEffect(() => {
const defaultEspacos = {
'bloco-a': { nome: 'Bloco A', tipo: 'Residencial', descricao: '10 andares • 20 Frações', icone: 'Building2', color: 'text-orange-500', bg: 'bg-orange-100 dark:bg-orange-900/40', border: 'border-orange-300 dark:border-orange-700/50', x: 8, y: 15, w: 20, h: 28, canBook: false, latitude: 38.7225, longitude: -9.1398 },
'bloco-b': { nome: 'Bloco B', tipo: 'Residencial', descricao: '8 andares • 16 Frações', icone: 'Building2', color: 'text-orange-500', bg: 'bg-orange-100 dark:bg-orange-900/40', border: 'border-orange-300 dark:border-orange-700/50', x: 8, y: 55, w: 20, h: 28, canBook: false, latitude: 38.7215, longitude: -9.1398 },
'mercado-1': { nome: 'Mini Mercado 1', tipo: 'Comércio', descricao: 'Bens de primeira necessidade', icone: 'ShoppingCart', color: 'text-amber-600', bg: 'bg-amber-100 dark:bg-amber-900/40', border: 'border-amber-300 dark:border-amber-700/50', x: 32, y: 20, w: 12, h: 15, canBook: false, latitude: 38.7228, longitude: -9.1390 },
'mercado-2': { nome: 'Mini Mercado 2', tipo: 'Comércio', descricao: 'Mercearia e cafetaria', icone: 'Store', color: 'text-amber-600', bg: 'bg-amber-100 dark:bg-amber-900/40', border: 'border-amber-300 dark:border-amber-700/50', x: 32, y: 65, w: 12, h: 15, canBook: false, latitude: 38.7212, longitude: -9.1390 },
'medico': { nome: 'Posto Médico', tipo: 'Serviços', descricao: 'Primeiros socorros e saúde', icone: 'HeartPulse', color: 'text-red-600', bg: 'bg-red-100 dark:bg-red-900/40', border: 'border-red-300 dark:border-red-700/50', x: 48, y: 20, w: 12, h: 15, canBook: false, latitude: 38.7228, longitude: -9.1385 },
'reception': { nome: 'Recepção', tipo: 'Serviços', descricao: 'Segurança 24h e Encomendas', icone: 'Info', color: 'text-slate-700 dark:text-slate-300', bg: 'bg-slate-200 dark:bg-slate-700', border: 'border-slate-400 dark:border-slate-500', x: 48, y: 45, w: 8, h: 12, isRound: true, canBook: false, latitude: 38.7220, longitude: -9.1385 },
'pool': { nome: 'Piscina', tipo: 'Lazer', descricao: 'Piscina exterior aquecida', icone: 'MapPin', color: 'text-teal-600', bg: 'bg-teal-100 dark:bg-teal-900/40', border: 'border-teal-300 dark:border-teal-700/50', x: 48, y: 65, w: 14, h: 18, canBook: false, latitude: 38.7212, longitude: -9.1385 },
'park': { nome: 'Parque de Jogos', tipo: 'Lazer', descricao: 'Campo Polidesportivo', icone: 'Trophy', color: 'text-green-600', bg: 'bg-green-100 dark:bg-green-900/40', border: 'border-green-300 dark:border-green-700/50', x: 65, y: 15, w: 18, h: 25, canBook: true, bookId: 'park', latitude: 38.7225, longitude: -9.1375 },
'gym': { nome: 'Ginásio', tipo: 'Lazer', descricao: 'Equipamento Cardio e Força', icone: 'Dumbbell', color: 'text-blue-600', bg: 'bg-blue-100 dark:bg-blue-900/40', border: 'border-blue-300 dark:border-blue-700/50', x: 65, y: 48, w: 14, h: 18, canBook: true, bookId: 'gym', latitude: 38.7218, longitude: -9.1375 },
'hall': { nome: 'Salão Festas', type: 'Lazer', descricao: 'Capacidade 50 px', icone: 'PartyPopper', color: 'text-purple-600', bg: 'bg-purple-100 dark:bg-purple-900/40', border: 'border-purple-300 dark:border-purple-700/50', x: 65, y: 72, w: 14, h: 18, canBook: true, bookId: 'hall', latitude: 38.7210, longitude: -9.1375 },
'deck': { nome: 'Deque do Rio', tipo: 'Lazer', descricao: 'Zona de relaxamento à beira rio', icone: 'Waves', color: 'text-cyan-700 dark:text-cyan-300', bg: 'bg-cyan-100 dark:bg-cyan-900/40', border: 'border-cyan-300 dark:border-cyan-700/50', x: 85, y: 40, w: 8, h: 30, canBook: false, latitude: 38.7220, longitude: -9.1360 },
};
const espacosRef = ref(db, 'espacos');
const unsub = onValue(espacosRef, (snapshot) => {
if (snapshot.exists()) {
const data = snapshot.val();
const loadedEspacos = Object.keys(data).map(key => ({ id: key, ...data[key] }));
setEspacos(loadedEspacos);
} else {
// Seed inicial da base de dados se estiver vazia
set(ref(db, 'espacos'), defaultEspacos).catch(console.error);
}
});
return () => unsub();
}, []);
const getDistance = (lat1, lon1, lat2, lon2) => {
const R = 6371; // Raio da Terra em km
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c; // em km
};
const handleRoute = (espaco) => {
setIsLocating(true);
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
const userLat = pos.coords.latitude;
const userLng = pos.coords.longitude;
const distKm = getDistance(userLat, userLng, espaco.latitude, espaco.longitude);
// Mapeamento da localização GPS do utilizador para as coordenadas visuais (x,y) do mapa
const minLat = 38.7205; const maxLat = 38.7230;
const minLng = -9.1405; const maxLng = -9.1350;
let userX = ((userLng - minLng) / (maxLng - minLng)) * 100;
let userY = ((maxLat - userLat) / (maxLat - minLat)) * 100;
// Se estiver fora do condomínio (> 5km), coloca o utilizador na entrada principal
if (distKm > 5) {
userX = 48; // Receção / Portaria
userY = 95; // Entrada
} else {
userX = Math.max(5, Math.min(95, userX));
userY = Math.max(5, Math.min(95, userY));
}
setRoute({
active: true,
targetId: espaco.id,
distance: distKm * 1000,
walkTime: Math.max(1, Math.ceil((distKm / 5) * 60)), // 5 km/h a pé
driveTime: Math.max(1, Math.ceil((distKm / 30) * 60)), // 30 km/h de carro
userX,
userY
});
setIsLocating(false);
},
(error) => {
console.error("Erro de geolocalização:", error);
alert("Não foi possível obter a sua localização. Verifique as permissões do browser.");
setIsLocating(false);
}
);
} else {
alert("Geolocalização não é suportada por este browser.");
setIsLocating(false);
}
};
return (
<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">Mapa do Condomínio</h3>
<p className="text-sm text-slate-500 dark:text-dark-mute">Plantas e Localizações</p>
<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 gap-2">
<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"><div className="w-2 h-2 rounded-full bg-blue-500"></div> Comum</span>
<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"><div className="w-2 h-2 rounded-full bg-orange-500"></div> Blocos</span>
<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-1 p-6 bg-slate-50 dark:bg-dark-bg overflow-auto flex items-center justify-center">
<div className="relative w-[800px] h-[500px] bg-white dark:bg-dark-surface border-2 border-slate-300 dark:border-dark-border rounded-xl shadow-xl p-8 transform hover:scale-[1.01] transition-transform duration-500">
<div className="absolute inset-0 opacity-5 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:50px_50px]"></div>
<div className="flex flex-col lg:flex-row flex-1 min-h-[500px]">
{/* Á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 max-w-[800px] aspect-[16/10] bg-white dark:bg-dark-surface border-2 border-slate-300 dark:border-dark-border rounded-xl shadow-xl p-4 transition-all duration-500 overflow-hidden">
<div className="absolute inset-0 opacity-5 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>
<div className="absolute top-1/2 left-0 w-full h-16 bg-slate-200 dark:bg-dark-card transform -translate-y-1/2 border-y-2 border-slate-300 dark:border-slate-600 border-dashed flex items-center justify-center text-slate-400 font-bold tracking-[1em] opacity-50">VIA CENTRAL</div>
<div className="absolute top-10 left-10 w-40 h-40 bg-orange-100 dark:bg-orange-900/40 border-2 border-orange-300 dark:border-orange-700/50 rounded-lg flex flex-col items-center justify-center hover:bg-orange-200 dark:hover:bg-orange-900/60 cursor-pointer transition-colors group">
<Building2 size={32} className="text-orange-500 mb-2" />
<span className="font-bold text-orange-800 dark:text-orange-200">Bloco A</span>
<div className="absolute -bottom-8 opacity-0 group-hover:opacity-100 bg-black text-white text-xs px-2 py-1 rounded transition-opacity">10 andares 20 Frações</div>
{/* Visual River */}
<div className="absolute right-0 top-0 bottom-0 w-[12%] bg-blue-400/20 dark:bg-blue-600/20 border-l-4 border-blue-300/30 dark:border-blue-700/30 flex items-center justify-center overflow-hidden pointer-events-none">
<div className="text-blue-500/30 dark:text-blue-400/20 font-bold text-3xl rotate-90 whitespace-nowrap tracking-[1em]">RIO</div>
</div>
<div className="absolute bottom-10 left-10 w-40 h-40 bg-orange-100 dark:bg-orange-900/40 border-2 border-orange-300 dark:border-orange-700/50 rounded-lg flex flex-col items-center justify-center hover:bg-orange-200 dark:hover:bg-orange-900/60 cursor-pointer transition-colors group">
<Building2 size={32} className="text-orange-500 mb-2" />
<span className="font-bold text-orange-800 dark:text-orange-200">Bloco B</span>
<div className="absolute -top-8 opacity-0 group-hover:opacity-100 bg-black text-white text-xs px-2 py-1 rounded transition-opacity">8 andares 16 Frações</div>
</div>
<div className="absolute top-1/2 left-0 w-[88%] h-12 bg-slate-200 dark:bg-dark-card transform -translate-y-1/2 border-y-2 border-slate-300 dark:border-slate-600 border-dashed flex items-center justify-center text-slate-400 font-bold tracking-[0.5em] opacity-40 text-xs sm:text-sm pointer-events-none">VIA CENTRAL</div>
<div className="absolute top-10 right-10 w-64 h-48 bg-green-100 dark:bg-green-900/40 border-2 border-green-300 dark:border-green-700/50 rounded-2xl flex flex-col items-center justify-center hover:bg-green-200 dark:hover:bg-green-900/60 cursor-pointer transition-colors group">
<Trophy size={40} className="text-green-600 mb-2" />
<span className="font-bold text-green-800 dark:text-green-200">Parque de Jogos</span>
<span className="text-xs text-green-600 dark:text-green-300">Campo Polidesportivo</span>
<div className="absolute -bottom-8 opacity-0 group-hover:opacity-100 bg-black text-white text-xs px-2 py-1 rounded transition-opacity z-10">Clique para reservar</div>
</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 => (
<path
key="route-path"
d={`M ${route.userX}% ${route.userY}% Q 50% 50% ${target.x + (target.w||0)/2}% ${target.y + (target.h||0)/2}%`}
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>
)}
<div className="absolute bottom-10 right-10 w-64 h-32 bg-blue-100 dark:bg-blue-900/40 border-2 border-blue-300 dark:border-blue-700/50 rounded-lg flex items-center justify-around hover:bg-blue-200 dark:hover:bg-blue-900/60 cursor-pointer transition-colors">
<div className="flex flex-col items-center group">
<PartyPopper size={24} className="text-blue-600 mb-1" />
<span className="text-xs font-bold text-blue-800 dark:text-blue-200">Salão Festas</span>
{espacos.map(loc => {
const IconComp = IconMap[loc.icone] || MapPin;
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-lg z-20 ring-4 ring-blue-400/30' : 'hover:scale-105 shadow-sm z-10'} ${loc.isRound ? 'rounded-full' : 'rounded-lg'}`}
style={{ left: `${loc.x}%`, top: `${loc.y}%`, width: `${loc.w}%`, height: `${loc.h}%` }}
>
<IconComp size={loc.isRound ? 16 : 24} className={`${loc.color} ${activePoint === loc.id ? 'animate-bounce' : ''}`} />
{!loc.isRound && (
<span className={`font-bold text-[9px] sm:text-[10px] mt-1 text-center px-1 text-slate-800 dark:text-slate-200 leading-tight`}>
{loc.nome}
</span>
)}
</div>
<div className="w-px h-20 bg-blue-300 dark:bg-blue-700"></div>
<div className="flex flex-col items-center group">
<Dumbbell size={24} className="text-blue-600 mb-1" />
<span className="text-xs font-bold text-blue-800 dark:text-blue-200">Ginásio</span>
);
})}
</div>
</div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-24 h-24 bg-slate-800 dark:bg-slate-700 rounded-full border-4 border-slate-200 dark:border-slate-600 shadow-xl flex flex-col items-center justify-center text-white z-10">
<Info size={24} />
<span className="text-[10px] mt-1 font-bold">Recepção</span>
{/* 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 :</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">

View File

@@ -5,7 +5,7 @@
TrendingUp, TrendingDown, CheckCircle, AlertCircle, Clock, LogOut,
Edit2, Trash2, Save, Filter, MoreVertical, FileText,
Dumbbell, PartyPopper, Trophy, Map, Calendar, MapPin, Info,
MessageCircle, Paperclip, Send
MessageCircle, Paperclip, Send, Store, HeartPulse, Waves, ShoppingCart, Navigation, Car
} from 'lucide-react';
import { app } from './firebase.js';
@@ -539,6 +539,30 @@
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);
@@ -814,7 +838,10 @@
});
const newPending = (Number(morador.pending) || 0) + valor;
await set(ref(db, `condominos/${morador.id}/pending`), newPending);
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');
@@ -834,8 +861,11 @@
const morador = residents.find(r => r.id === fatura.moradorId);
if (morador) {
let newPending = (Number(morador.pending) || 0) - Number(fatura.valor);
if (newPending < 0) newPending = 0;
await set(ref(db, `condominos/${morador.id}/pending`), newPending);
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');
@@ -853,8 +883,11 @@
const morador = residents.find(r => r.id === fatura.moradorId);
if (morador) {
let newPending = (Number(morador.pending) || 0) - Number(fatura.valor);
if (newPending < 0) newPending = 0;
await set(ref(db, `condominos/${morador.id}/pending`), newPending);
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');
@@ -1068,64 +1101,257 @@
);
};
const MapView = () => (
const MapView = () => {
const [activePoint, setActivePoint] = useState(null);
const [espacos, setEspacos] = useState([]);
const [route, setRoute] = useState(null);
const [isLocating, setIsLocating] = useState(false);
const IconMap = { Building2, ShoppingCart, Store, HeartPulse, Info, MapPin, Trophy, Dumbbell, PartyPopper, Waves };
useEffect(() => {
const defaultEspacos = {
'bloco-a': { nome: 'Bloco A', tipo: 'Residencial', descricao: '10 andares • 20 Frações', icone: 'Building2', color: 'text-orange-500', bg: 'bg-orange-100 dark:bg-orange-900/40', border: 'border-orange-300 dark:border-orange-700/50', x: 8, y: 15, w: 20, h: 28, canBook: false, latitude: 38.7225, longitude: -9.1398 },
'bloco-b': { nome: 'Bloco B', tipo: 'Residencial', descricao: '8 andares • 16 Frações', icone: 'Building2', color: 'text-orange-500', bg: 'bg-orange-100 dark:bg-orange-900/40', border: 'border-orange-300 dark:border-orange-700/50', x: 8, y: 55, w: 20, h: 28, canBook: false, latitude: 38.7215, longitude: -9.1398 },
'mercado-1': { nome: 'Mini Mercado 1', tipo: 'Comércio', descricao: 'Bens de primeira necessidade', icone: 'ShoppingCart', color: 'text-amber-600', bg: 'bg-amber-100 dark:bg-amber-900/40', border: 'border-amber-300 dark:border-amber-700/50', x: 32, y: 20, w: 12, h: 15, canBook: false, latitude: 38.7228, longitude: -9.1390 },
'mercado-2': { nome: 'Mini Mercado 2', tipo: 'Comércio', descricao: 'Mercearia e cafetaria', icone: 'Store', color: 'text-amber-600', bg: 'bg-amber-100 dark:bg-amber-900/40', border: 'border-amber-300 dark:border-amber-700/50', x: 32, y: 65, w: 12, h: 15, canBook: false, latitude: 38.7212, longitude: -9.1390 },
'medico': { nome: 'Posto Médico', tipo: 'Serviços', descricao: 'Primeiros socorros e saúde', icone: 'HeartPulse', color: 'text-red-600', bg: 'bg-red-100 dark:bg-red-900/40', border: 'border-red-300 dark:border-red-700/50', x: 48, y: 20, w: 12, h: 15, canBook: false, latitude: 38.7228, longitude: -9.1385 },
'reception': { nome: 'Recepção', tipo: 'Serviços', descricao: 'Segurança 24h e Encomendas', icone: 'Info', color: 'text-slate-700 dark:text-slate-300', bg: 'bg-slate-200 dark:bg-slate-700', border: 'border-slate-400 dark:border-slate-500', x: 48, y: 45, w: 8, h: 12, isRound: true, canBook: false, latitude: 38.7220, longitude: -9.1385 },
'pool': { nome: 'Piscina', tipo: 'Lazer', descricao: 'Piscina exterior aquecida', icone: 'MapPin', color: 'text-teal-600', bg: 'bg-teal-100 dark:bg-teal-900/40', border: 'border-teal-300 dark:border-teal-700/50', x: 48, y: 65, w: 14, h: 18, canBook: false, latitude: 38.7212, longitude: -9.1385 },
'park': { nome: 'Parque de Jogos', tipo: 'Lazer', descricao: 'Campo Polidesportivo', icone: 'Trophy', color: 'text-green-600', bg: 'bg-green-100 dark:bg-green-900/40', border: 'border-green-300 dark:border-green-700/50', x: 65, y: 15, w: 18, h: 25, canBook: true, bookId: 'park', latitude: 38.7225, longitude: -9.1375 },
'gym': { nome: 'Ginásio', tipo: 'Lazer', descricao: 'Equipamento Cardio e Força', icone: 'Dumbbell', color: 'text-blue-600', bg: 'bg-blue-100 dark:bg-blue-900/40', border: 'border-blue-300 dark:border-blue-700/50', x: 65, y: 48, w: 14, h: 18, canBook: true, bookId: 'gym', latitude: 38.7218, longitude: -9.1375 },
'hall': { nome: 'Salão Festas', type: 'Lazer', descricao: 'Capacidade 50 px', icone: 'PartyPopper', color: 'text-purple-600', bg: 'bg-purple-100 dark:bg-purple-900/40', border: 'border-purple-300 dark:border-purple-700/50', x: 65, y: 72, w: 14, h: 18, canBook: true, bookId: 'hall', latitude: 38.7210, longitude: -9.1375 },
'deck': { nome: 'Deque do Rio', tipo: 'Lazer', descricao: 'Zona de relaxamento à beira rio', icone: 'Waves', color: 'text-cyan-700 dark:text-cyan-300', bg: 'bg-cyan-100 dark:bg-cyan-900/40', border: 'border-cyan-300 dark:border-cyan-700/50', x: 85, y: 40, w: 8, h: 30, canBook: false, latitude: 38.7220, longitude: -9.1360 },
};
const espacosRef = ref(db, 'espacos');
const unsub = onValue(espacosRef, (snapshot) => {
if (snapshot.exists()) {
const data = snapshot.val();
const loadedEspacos = Object.keys(data).map(key => ({ id: key, ...data[key] }));
setEspacos(loadedEspacos);
} else {
// Seed inicial da base de dados se estiver vazia
set(ref(db, 'espacos'), defaultEspacos).catch(console.error);
}
});
return () => unsub();
}, []);
const getDistance = (lat1, lon1, lat2, lon2) => {
const R = 6371; // Raio da Terra em km
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c; // em km
};
const handleRoute = (espaco) => {
setIsLocating(true);
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
const userLat = pos.coords.latitude;
const userLng = pos.coords.longitude;
const distKm = getDistance(userLat, userLng, espaco.latitude, espaco.longitude);
// Mapeamento da localização GPS do utilizador para as coordenadas visuais (x,y) do mapa
const minLat = 38.7205; const maxLat = 38.7230;
const minLng = -9.1405; const maxLng = -9.1350;
let userX = ((userLng - minLng) / (maxLng - minLng)) * 100;
let userY = ((maxLat - userLat) / (maxLat - minLat)) * 100;
// Se estiver fora do condomínio (> 5km), coloca o utilizador na entrada principal
if (distKm > 5) {
userX = 48; // Receção / Portaria
userY = 95; // Entrada
} else {
userX = Math.max(5, Math.min(95, userX));
userY = Math.max(5, Math.min(95, userY));
}
setRoute({
active: true,
targetId: espaco.id,
distance: distKm * 1000,
walkTime: Math.max(1, Math.ceil((distKm / 5) * 60)), // 5 km/h a pé
driveTime: Math.max(1, Math.ceil((distKm / 30) * 60)), // 30 km/h de carro
userX,
userY
});
setIsLocating(false);
},
(error) => {
console.error("Erro de geolocalização:", error);
alert("Não foi possível obter a sua localização. Verifique as permissões do browser.");
setIsLocating(false);
}
);
} else {
alert("Geolocalização não é suportada por este browser.");
setIsLocating(false);
}
};
return (
<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">Mapa do Condomínio</h3>
<p className="text-sm text-slate-500 dark:text-dark-mute">Plantas e Localizações</p>
<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 gap-2">
<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"><div className="w-2 h-2 rounded-full bg-blue-500"></div> Comum</span>
<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"><div className="w-2 h-2 rounded-full bg-orange-500"></div> Blocos</span>
<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-1 p-6 bg-slate-50 dark:bg-dark-bg overflow-auto flex items-center justify-center">
<div className="relative w-[800px] h-[500px] bg-white dark:bg-dark-surface border-2 border-slate-300 dark:border-dark-border rounded-xl shadow-xl p-8 transform hover:scale-[1.01] transition-transform duration-500">
<div className="absolute inset-0 opacity-5 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:50px_50px]"></div>
<div className="flex flex-col lg:flex-row flex-1 min-h-[500px]">
{/* Á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 max-w-[800px] aspect-[16/10] bg-white dark:bg-dark-surface border-2 border-slate-300 dark:border-dark-border rounded-xl shadow-xl p-4 transition-all duration-500 overflow-hidden">
<div className="absolute inset-0 opacity-5 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>
<div className="absolute top-1/2 left-0 w-full h-16 bg-slate-200 dark:bg-dark-card transform -translate-y-1/2 border-y-2 border-slate-300 dark:border-slate-600 border-dashed flex items-center justify-center text-slate-400 font-bold tracking-[1em] opacity-50">VIA CENTRAL</div>
<div className="absolute top-10 left-10 w-40 h-40 bg-orange-100 dark:bg-orange-900/40 border-2 border-orange-300 dark:border-orange-700/50 rounded-lg flex flex-col items-center justify-center hover:bg-orange-200 dark:hover:bg-orange-900/60 cursor-pointer transition-colors group">
<Building2 size={32} className="text-orange-500 mb-2" />
<span className="font-bold text-orange-800 dark:text-orange-200">Bloco A</span>
<div className="absolute -bottom-8 opacity-0 group-hover:opacity-100 bg-black text-white text-xs px-2 py-1 rounded transition-opacity">10 andares 20 Frações</div>
{/* Visual River */}
<div className="absolute right-0 top-0 bottom-0 w-[12%] bg-blue-400/20 dark:bg-blue-600/20 border-l-4 border-blue-300/30 dark:border-blue-700/30 flex items-center justify-center overflow-hidden pointer-events-none">
<div className="text-blue-500/30 dark:text-blue-400/20 font-bold text-3xl rotate-90 whitespace-nowrap tracking-[1em]">RIO</div>
</div>
<div className="absolute bottom-10 left-10 w-40 h-40 bg-orange-100 dark:bg-orange-900/40 border-2 border-orange-300 dark:border-orange-700/50 rounded-lg flex flex-col items-center justify-center hover:bg-orange-200 dark:hover:bg-orange-900/60 cursor-pointer transition-colors group">
<Building2 size={32} className="text-orange-500 mb-2" />
<span className="font-bold text-orange-800 dark:text-orange-200">Bloco B</span>
<div className="absolute -top-8 opacity-0 group-hover:opacity-100 bg-black text-white text-xs px-2 py-1 rounded transition-opacity">8 andares 16 Frações</div>
</div>
<div className="absolute top-1/2 left-0 w-[88%] h-12 bg-slate-200 dark:bg-dark-card transform -translate-y-1/2 border-y-2 border-slate-300 dark:border-slate-600 border-dashed flex items-center justify-center text-slate-400 font-bold tracking-[0.5em] opacity-40 text-xs sm:text-sm pointer-events-none">VIA CENTRAL</div>
<div className="absolute top-10 right-10 w-64 h-48 bg-green-100 dark:bg-green-900/40 border-2 border-green-300 dark:border-green-700/50 rounded-2xl flex flex-col items-center justify-center hover:bg-green-200 dark:hover:bg-green-900/60 cursor-pointer transition-colors group">
<Trophy size={40} className="text-green-600 mb-2" />
<span className="font-bold text-green-800 dark:text-green-200">Parque de Jogos</span>
<span className="text-xs text-green-600 dark:text-green-300">Campo Polidesportivo</span>
<div className="absolute -bottom-8 opacity-0 group-hover:opacity-100 bg-black text-white text-xs px-2 py-1 rounded transition-opacity z-10">Clique para reservar</div>
</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 => (
<path
key="route-path"
d={`M ${route.userX}% ${route.userY}% Q 50% 50% ${target.x + (target.w||0)/2}% ${target.y + (target.h||0)/2}%`}
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>
)}
<div className="absolute bottom-10 right-10 w-64 h-32 bg-blue-100 dark:bg-blue-900/40 border-2 border-blue-300 dark:border-blue-700/50 rounded-lg flex items-center justify-around hover:bg-blue-200 dark:hover:bg-blue-900/60 cursor-pointer transition-colors">
<div className="flex flex-col items-center group">
<PartyPopper size={24} className="text-blue-600 mb-1" />
<span className="text-xs font-bold text-blue-800 dark:text-blue-200">Salão Festas</span>
{espacos.map(loc => {
const IconComp = IconMap[loc.icone] || MapPin;
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-lg z-20 ring-4 ring-blue-400/30' : 'hover:scale-105 shadow-sm z-10'} ${loc.isRound ? 'rounded-full' : 'rounded-lg'}`}
style={{ left: `${loc.x}%`, top: `${loc.y}%`, width: `${loc.w}%`, height: `${loc.h}%` }}
>
<IconComp size={loc.isRound ? 16 : 24} className={`${loc.color} ${activePoint === loc.id ? 'animate-bounce' : ''}`} />
{!loc.isRound && (
<span className={`font-bold text-[9px] sm:text-[10px] mt-1 text-center px-1 text-slate-800 dark:text-slate-200 leading-tight`}>
{loc.nome}
</span>
)}
</div>
<div className="w-px h-20 bg-blue-300 dark:bg-blue-700"></div>
<div className="flex flex-col items-center group">
<Dumbbell size={24} className="text-blue-600 mb-1" />
<span className="text-xs font-bold text-blue-800 dark:text-blue-200">Ginásio</span>
);
})}
</div>
</div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-24 h-24 bg-slate-800 dark:bg-slate-700 rounded-full border-4 border-slate-200 dark:border-slate-600 shadow-xl flex flex-col items-center justify-center text-white z-10">
<Info size={24} />
<span className="text-[10px] mt-1 font-bold">Recepção</span>
{/* 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 :</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">