chore: add project files and setup gitignore

This commit is contained in:
2026-05-08 10:25:14 +01:00
parent ea29a2f3f3
commit 70a62021a2
58 changed files with 13404 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="w-full max-w-md p-4">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import React, { useState } from "react";
import { signInWithEmailAndPassword } from "firebase/auth";
import { auth } from "@/lib/firebase";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await signInWithEmailAndPassword(auth, email, password);
router.push("/");
} catch (err: any) {
console.error(err);
setError("Credenciais inválidas. Verifique o seu email e palavra-passe.");
} finally {
setLoading(false);
}
};
return (
<Card className="w-full">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-3xl text-primary">ReservaMesa</CardTitle>
<CardDescription>
Inicie sessão no seu painel de restaurante
</CardDescription>
</CardHeader>
<form onSubmit={handleLogin}>
<CardContent className="space-y-4">
{error && (
<div className="bg-destructive/15 text-destructive text-sm p-3 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="restaurante@exemplo.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Palavra-passe</Label>
</div>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "A entrar..." : "Entrar"}
</Button>
<div className="text-center text-sm text-muted-foreground">
Ainda não tem conta?{" "}
<Link href="/register" className="text-primary hover:underline">
Registe o seu restaurante
</Link>
</div>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import React, { useState } from "react";
import { createUserWithEmailAndPassword } from "firebase/auth";
import { ref, set } from "firebase/database";
import { auth, db } from "@/lib/firebase";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
export default function RegisterPage() {
const [formData, setFormData] = useState({
ownerName: "",
ownerPhone: "",
establishmentName: "",
email: "",
establishmentPhone: "",
password: "",
});
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.id]: e.target.value });
};
const buildDocumentId = (email: string) => {
return email.replace(/\./g, "_").replace(/@/g, "_at_");
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
// 1. Criar utilizador na Firebase Auth
const userCredential = await createUserWithEmailAndPassword(auth, formData.email, formData.password);
const user = userCredential.user;
// 2. Preparar payload conforme a App Android
const documentId = buildDocumentId(formData.email);
const payload = {
uid: user.uid,
email: formData.email,
displayName: formData.establishmentName,
role: "ADMIN",
accountType: "ESTABELECIMENTO",
createdAt: Date.now(),
ownerName: formData.ownerName,
ownerEmail: formData.email,
ownerPhone: formData.ownerPhone,
establishmentName: formData.establishmentName,
establishmentEmail: formData.email,
establishmentPhone: formData.establishmentPhone,
};
// 3. Gravar na Realtime Database em /Restaurantes
await set(ref(db, `Restaurantes/${documentId}`), payload);
router.push("/");
} catch (err: any) {
console.error(err);
setError(err.message || "Ocorreu um erro ao registar o restaurante.");
} finally {
setLoading(false);
}
};
return (
<Card className="w-full max-w-lg mx-auto">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-3xl text-primary">Novo Restaurante</CardTitle>
<CardDescription>
Crie a sua conta de gestão no ReservaMesa
</CardDescription>
</CardHeader>
<form onSubmit={handleRegister}>
<CardContent className="space-y-4">
{error && (
<div className="bg-destructive/15 text-destructive text-sm p-3 rounded-md">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="establishmentName">Nome do Restaurante</Label>
<Input id="establishmentName" value={formData.establishmentName} onChange={handleChange} required />
</div>
<div className="space-y-2">
<Label htmlFor="establishmentPhone">Telefone do Restaurante</Label>
<Input id="establishmentPhone" type="tel" value={formData.establishmentPhone} onChange={handleChange} required />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ownerName">Nome do Responsável</Label>
<Input id="ownerName" value={formData.ownerName} onChange={handleChange} required />
</div>
<div className="space-y-2">
<Label htmlFor="ownerPhone">Telemóvel Pessoal</Label>
<Input id="ownerPhone" type="tel" value={formData.ownerPhone} onChange={handleChange} required />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Principal (Login)</Label>
<Input id="email" type="email" value={formData.email} onChange={handleChange} required />
</div>
<div className="space-y-2">
<Label htmlFor="password">Palavra-passe</Label>
<Input id="password" type="password" value={formData.password} onChange={handleChange} required minLength={6} />
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "A registar..." : "Registar e Entrar"}
</Button>
<div className="text-center text-sm text-muted-foreground">
tem conta?{" "}
<Link href="/login" className="text-primary hover:underline">
Iniciar sessão
</Link>
</div>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -0,0 +1,159 @@
"use client";
import React, { useState, useEffect } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Store, Mail, Phone, MapPin, Save, CheckCircle2 } from "lucide-react";
export default function ConfiguracoesPage() {
const { user, updateRestaurantProfile } = useAuth();
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [formData, setFormData] = useState({
establishmentName: "",
category: "",
phoneNumber: "",
address: "",
isAvailable: true,
});
useEffect(() => {
if (user) {
setFormData({
establishmentName: user.establishmentName || "",
category: user.category || "",
phoneNumber: user.phoneNumber || "",
address: user.address || "",
isAvailable: user.isAvailable !== false, // default true
});
}
}, [user]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setSuccess(false);
const res = await updateRestaurantProfile(formData);
if (res.success) {
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
}
setLoading(false);
};
return (
<div className="max-w-4xl space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-display font-bold text-foreground">Configurações</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<Card className="border-border/50">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Perfil do Estabelecimento</CardTitle>
<CardDescription>Gerencie as informações que os clientes veem na App.</CardDescription>
</div>
<div className="flex items-center gap-3 bg-muted/30 px-4 py-2 rounded-lg border">
<Label htmlFor="available" className="text-sm font-medium">Estado do Restaurante</Label>
<Switch
id="available"
checked={formData.isAvailable}
onCheckedChange={(checked) => setFormData({...formData, isAvailable: checked})}
/>
<span className={`text-xs font-bold uppercase ${formData.isAvailable ? "text-green-500" : "text-destructive"}`}>
{formData.isAvailable ? "Aberto" : "Fechado"}
</span>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Nome do Estabelecimento</Label>
<div className="relative">
<Store className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="name"
className="pl-10"
value={formData.establishmentName}
onChange={(e) => setFormData({...formData, establishmentName: e.target.value})}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="category">Categoria / Cozinha</Label>
<Input
id="category"
value={formData.category}
onChange={(e) => setFormData({...formData, category: e.target.value})}
placeholder="Ex: Portuguesa, Italiana..."
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">Email de Contacto (Não editável)</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="email"
className="pl-10 bg-muted/50"
value={user?.email || ""}
disabled
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefone</Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="phone"
className="pl-10"
value={formData.phoneNumber}
onChange={(e) => setFormData({...formData, phoneNumber: e.target.value})}
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address">Morada Completa</Label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="address"
className="pl-10"
value={formData.address}
onChange={(e) => setFormData({...formData, address: e.target.value})}
/>
</div>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-end gap-4">
{success && (
<div className="flex items-center gap-2 text-green-500 font-medium animate-in fade-in slide-in-from-right-4">
<CheckCircle2 className="h-5 w-5" /> Alterações guardadas!
</div>
)}
<Button type="submit" disabled={loading} className="px-8 gap-2">
{loading ? "A guardar..." : <><Save className="h-4 w-4" /> Guardar Alterações</>}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,164 @@
"use client";
import React, { useState } from "react";
import { useStaff } from "@/hooks/useStaff";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Users,
UserPlus,
Trash2,
Mail,
Phone,
Briefcase,
Search,
Plus
} from "lucide-react";
export default function EquipaPage() {
const { staff, loading, addStaff, deleteStaff } = useStaff();
const [searchTerm, setSearchTerm] = useState("");
const [isAdding, setIsAdding] = useState(false);
const [newMember, setNewMember] = useState({
name: "",
role: "",
email: "",
phoneNumber: ""
});
const filteredStaff = staff.filter(s =>
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.role.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
const res = await addStaff(newMember);
if (res.success) {
setIsAdding(false);
setNewMember({ name: "", role: "", email: "", phoneNumber: "" });
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-display font-bold text-foreground">Gestão de Equipa</h1>
<Button onClick={() => setIsAdding(!isAdding)} className="gap-2">
{isAdding ? "Cancelar" : <><UserPlus className="h-4 w-4" /> Adicionar Funcionário</>}
</Button>
</div>
{isAdding && (
<Card className="border-primary/20 bg-primary/5">
<CardHeader>
<CardTitle>Novo Funcionário</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleAdd} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Nome</Label>
<Input
id="name"
value={newMember.name}
onChange={e => setNewMember({...newMember, name: e.target.value})}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Cargo</Label>
<Input
id="role"
value={newMember.role}
onChange={e => setNewMember({...newMember, role: e.target.value})}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={newMember.email}
onChange={e => setNewMember({...newMember, email: e.target.value})}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefone</Label>
<div className="flex gap-2">
<Input
id="phone"
value={newMember.phoneNumber}
onChange={e => setNewMember({...newMember, phoneNumber: e.target.value})}
/>
<Button type="submit">Adicionar</Button>
</div>
</div>
</form>
</CardContent>
</Card>
)}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Pesquisar por nome ou cargo..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{loading ? (
<div className="col-span-full flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : filteredStaff.length > 0 ? (
filteredStaff.map((member) => (
<Card key={member.id} className="overflow-hidden border-border/50 hover:shadow-md transition-all group">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xl">
{member.name.charAt(0)}
</div>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => deleteStaff(member.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<CardTitle className="mt-4">{member.name}</CardTitle>
<CardDescription className="flex items-center gap-1">
<Briefcase className="h-3 w-3" /> {member.role}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 pt-4 border-t border-border/50">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Mail className="h-4 w-4" /> {member.email}
</div>
{member.phoneNumber && (
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Phone className="h-4 w-4" /> {member.phoneNumber}
</div>
)}
</CardContent>
</Card>
))
) : (
<div className="col-span-full flex flex-col items-center justify-center py-20 text-center border-2 border-dashed rounded-xl">
<Users className="h-12 w-12 text-muted-foreground/20 mb-4" />
<h3 className="text-lg font-medium">Nenhum funcionário encontrado</h3>
<p className="text-sm text-muted-foreground">Adicione membros à sua equipa para começar.</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import { useReservas } from "@/hooks/useReservas";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { History, Calendar, User, Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useState } from "react";
export default function HistoricoPage() {
const { reservas, loading } = useReservas();
const [searchTerm, setSearchTerm] = useState("");
const historico = reservas.filter(r =>
["Concluída", "Recusada", "Cancelada"].includes(r.estado) ||
(r.estado.includes("Confirmada") && new Date(r.data) < new Date())
);
const filtered = historico.filter(r =>
r.clienteEmail.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-display font-bold text-foreground">Histórico de Reservas</h1>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Pesquisar por email do cliente..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="grid gap-4">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : filtered.length > 0 ? (
filtered.map((reserva) => (
<Card key={reserva.id} className="border-border/40 bg-card/50">
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`h-10 w-10 rounded-full flex items-center justify-center ${
reserva.estado === "Concluída" ? "bg-green-500/10 text-green-500" : "bg-destructive/10 text-destructive"
}`}>
<History className="h-5 w-5" />
</div>
<div>
<p className="font-medium">{reserva.clienteEmail}</p>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-0.5">
<span className="flex items-center gap-1"><Calendar className="h-3 w-3" /> {reserva.data}</span>
<span className="flex items-center gap-1"><User className="h-3 w-3" /> {reserva.pessoas} p.</span>
</div>
</div>
</div>
<div className="text-right">
<span className={`text-[10px] font-bold uppercase px-2 py-0.5 rounded border ${
reserva.estado === "Concluída" ? "border-green-500/20 text-green-500" : "border-destructive/20 text-destructive"
}`}>
{reserva.estado}
</span>
</div>
</CardContent>
</Card>
))
) : (
<div className="flex flex-col items-center justify-center py-20 text-center text-muted-foreground border-2 border-dashed rounded-xl">
<History className="h-12 w-12 opacity-20 mb-4" />
<p>Nenhum registo encontrado no histórico.</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import React from "react";
import AuthGuard from "@/components/auth/AuthGuard";
import { Sidebar } from "@/components/dashboard/Sidebar";
import { MobileNav } from "@/components/dashboard/MobileNav";
import { Header } from "@/components/dashboard/Header";
import { NotificationMonitor } from "@/components/dashboard/NotificationMonitor";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthGuard>
<NotificationMonitor />
<div className="flex min-h-screen bg-background">
{/* Mobile Navigation */}
<MobileNav />
{/* Desktop Sidebar */}
<aside className="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0">
<Sidebar />
</aside>
{/* Main Content Area */}
<div className="md:pl-64 flex flex-col flex-1">
<Header />
<main className="flex-1">
<div className="py-6 px-4 sm:px-6 md:px-8">
{children}
</div>
</main>
</div>
</div>
</AuthGuard>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { useReservas } from "@/hooks/useReservas";
import { useMesas } from "@/hooks/useMesas";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { AssignTableModal } from "@/components/dashboard/AssignTableModal";
import { Clock, User, ListOrdered, Check, X } from "lucide-react";
import { Reserva } from "@/types/reserva";
import { Mesa } from "@/types/mesa";
import { useToast } from "@/components/ui/toast";
export default function ListaEsperaPage() {
const { reservas, loading: loadingReservas, confirmarComMesa, updateReservaEstado } = useReservas();
const { mesas, loading: loadingMesas } = useMesas();
const { toast } = useToast();
const [selectedReserva, setSelectedReserva] = useState<Reserva | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const reservasPendentes = reservas.filter(r => r.estado === "Pendente");
const handleOpenAssign = (reserva: Reserva) => {
setSelectedReserva(reserva);
setIsModalOpen(true);
};
const handleAssign = async (mesa: Mesa) => {
if (selectedReserva) {
let res;
if (mesa.numero === 0) {
// Confirm without table
res = await updateReservaEstado(selectedReserva.id, "Confirmada");
if (res.success) toast("Reserva confirmada sem mesa.", "info");
} else {
res = await confirmarComMesa(selectedReserva.id, mesa.id, mesa.numero);
if (res.success) toast(`Reserva confirmada na Mesa ${mesa.numero}.`, "success");
}
if (!res.success) toast("Erro ao atualizar reserva.", "error");
setIsModalOpen(false);
setSelectedReserva(null);
}
};
const handleRecusar = async (id: string) => {
const res = await updateReservaEstado(id, "Recusada");
if (res.success) {
toast("Reserva recusada.", "info");
} else {
toast("Erro ao recusar reserva.", "error");
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-display font-bold text-foreground">Lista de Espera</h1>
<div className="flex items-center gap-2 text-sm font-medium text-primary bg-primary/10 px-4 py-2 rounded-full border border-primary/20">
<ListOrdered className="h-5 w-5" />
<span>{reservasPendentes.length} Pendentes</span>
</div>
</div>
<div className="grid gap-4">
{loadingReservas || loadingMesas ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : reservasPendentes.length > 0 ? (
reservasPendentes.map((reserva) => (
<Card key={reserva.id} className="overflow-hidden border-border/50 hover:border-primary/30 transition-all duration-200">
<CardContent className="p-0">
<div className="flex flex-col md:flex-row md:items-center p-6 gap-6">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<Clock className="h-3 w-3" /> {reserva.hora}
</div>
<h3 className="text-xl font-bold">{reserva.clienteEmail}</h3>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<User className="h-4 w-4" /> {reserva.pessoas} pessoas
</p>
</div>
<div className="flex items-center gap-3">
<Button
onClick={() => handleOpenAssign(reserva)}
className="bg-primary hover:bg-primary/90"
>
<Check className="h-4 w-4 mr-2" /> Atribuir Mesa
</Button>
<Button
onClick={() => handleRecusar(reserva.id)}
variant="ghost"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<X className="h-4 w-4 mr-2" /> Recusar
</Button>
</div>
</div>
</CardContent>
</Card>
))
) : (
<div className="flex flex-col items-center justify-center py-24 text-center border-2 border-dashed rounded-xl bg-card/30">
<ListOrdered className="h-16 w-16 text-muted-foreground/20 mb-4" />
<h3 className="text-xl font-medium">A lista está limpa!</h3>
<p className="text-muted-foreground max-w-xs mx-auto">
Não reservas pendentes de aprovação neste momento.
</p>
</div>
)}
</div>
<AssignTableModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
reserva={selectedReserva}
mesas={mesas}
onAssign={handleAssign}
/>
</div>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
import React, { useState } from "react";
import { useMesas } from "@/hooks/useMesas";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table as TableIcon, Users, Plus, Trash2 } from "lucide-react";
import { useToast } from "@/components/ui/toast";
export default function MesasPage() {
const { mesas, loading, updateMesaEstado, addMesa, deleteMesa } = useMesas();
const { toast } = useToast();
const [showAddForm, setShowAddForm] = useState(false);
const [newMesa, setNewMesa] = useState({ numero: "", capacidade: "" });
const handleAddMesa = async (e: React.FormEvent) => {
e.preventDefault();
const res = await addMesa(parseInt(newMesa.numero), parseInt(newMesa.capacidade));
if (res.success) {
setNewMesa({ numero: "", capacidade: "" });
setShowAddForm(false);
toast("Mesa adicionada com sucesso!", "success");
} else {
toast("Erro ao adicionar mesa.", "error");
}
};
const handleDelete = async (id: string) => {
const res = await deleteMesa(id);
if (res.success) {
toast("Mesa removida.", "success");
}
};
const handleUpdateEstado = async (id: string, estado: any) => {
const res = await updateMesaEstado(id, estado);
if (res.success) {
toast(`Estado da mesa atualizado para ${estado}`, "info");
}
};
const getStatusColor = (estado: string) => {
switch (estado) {
case "Ocupada": return "bg-primary text-primary-foreground border-primary";
case "Reservada": return "bg-amber-500 text-white border-amber-500";
default: return "bg-muted text-muted-foreground border-border";
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-display font-bold text-foreground">Estado das Mesas</h1>
<Button onClick={() => setShowAddForm(!showAddForm)} className="gap-2">
<Plus className="h-4 w-4" /> Nova Mesa
</Button>
</div>
{showAddForm && (
<Card className="border-primary/30 bg-primary/5">
<CardHeader>
<CardTitle className="text-lg">Adicionar Nova Mesa</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleAddMesa} className="flex flex-wrap items-end gap-4">
<div className="space-y-2">
<Label htmlFor="numero">Número da Mesa</Label>
<Input
id="numero"
type="number"
value={newMesa.numero}
onChange={(e) => setNewMesa({...newMesa, numero: e.target.value})}
required
className="w-32"
/>
</div>
<div className="space-y-2">
<Label htmlFor="capacidade">Capacidade (Lugares)</Label>
<Input
id="capacidade"
type="number"
value={newMesa.capacidade}
onChange={(e) => setNewMesa({...newMesa, capacidade: e.target.value})}
required
className="w-32"
/>
</div>
<Button type="submit">Criar Mesa</Button>
<Button type="button" variant="ghost" onClick={() => setShowAddForm(false)}>Cancelar</Button>
</form>
</CardContent>
</Card>
)}
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{mesas.map((mesa) => (
<Card key={mesa.id} className="relative group overflow-hidden border-border/50 hover:border-primary/30 transition-all duration-200">
<CardContent className="p-6 flex flex-col items-center text-center">
<div className={`mb-4 flex h-16 w-16 items-center justify-center rounded-2xl border-2 font-display text-2xl font-bold transition-colors ${getStatusColor(mesa.estado)}`}>
{mesa.numero}
</div>
<div className="space-y-1">
<p className="font-bold uppercase text-[10px] tracking-widest text-muted-foreground">Estado</p>
<p className="text-sm font-medium">{mesa.estado}</p>
</div>
<div className="mt-4 flex items-center gap-2 text-xs text-muted-foreground bg-muted px-2 py-1 rounded-md">
<Users className="h-3 w-3" /> {mesa.capacidade} lugares
</div>
<div className="mt-6 flex gap-2">
<select
className="text-xs bg-background border rounded px-1"
value={mesa.estado}
onChange={(e) => handleUpdateEstado(mesa.id, e.target.value as any)}
>
<option value="Livre">Livre</option>
<option value="Ocupada">Ocupada</option>
<option value="Reservada">Reservada</option>
</select>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(mesa.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
{mesas.length === 0 && !showAddForm && (
<div className="col-span-full py-20 flex flex-col items-center justify-center text-center text-muted-foreground border-2 border-dashed rounded-xl">
<TableIcon className="h-12 w-12 opacity-20 mb-4" />
<p>Nenhuma mesa configurada.</p>
<Button variant="link" onClick={() => setShowAddForm(true)}>Clique aqui para adicionar a primeira mesa</Button>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,182 @@
"use client";
import { useAuth } from "@/hooks/useAuth";
import { useReservas } from "@/hooks/useReservas";
import { useMesas } from "@/hooks/useMesas";
import { useStaff } from "@/hooks/useStaff";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { OverviewChart } from "@/components/dashboard/OverviewChart";
import { OccupancyPieChart } from "@/components/dashboard/OccupancyPieChart";
import {
Users,
CalendarCheck,
Clock,
TrendingUp,
UserCheck
} from "lucide-react";
export default function DashboardHomePage() {
const { user } = useAuth();
const { reservas, loading: loadingReservas } = useReservas();
const { mesas, loading: loadingMesas } = useMesas();
const { staff } = useStaff();
// 1. Calculate top stats
const todayStr = new Date().toISOString().split('T')[0];
const todayReservas = reservas.filter(r => r.data === todayStr || r.estado.startsWith("Confirmada"));
const activeReservas = todayReservas.filter(r => r.estado.startsWith("Confirmada")).length;
const pendingReservas = todayReservas.filter(r => r.estado === "Pendente").length;
const totalMesas = mesas.length;
const occupiedMesas = mesas.filter(m => m.estado === "Ocupada").length;
const reservedMesas = mesas.filter(m => m.estado === "Reservada").length;
const freeMesas = totalMesas - occupiedMesas - reservedMesas;
const occupancyRate = totalMesas > 0 ? Math.round(((occupiedMesas + reservedMesas) / totalMesas) * 100) : 0;
const stats = [
{ name: "Reservas Hoje", value: todayReservas.length.toString(), icon: CalendarCheck, trend: `+${pendingReservas} pendentes` },
{ name: "Mesas Ocupadas", value: `${occupiedMesas} / ${totalMesas}`, icon: Clock, trend: `${freeMesas} livres` },
{ name: "Staff Ativo", value: staff.length.toString(), icon: UserCheck, trend: "Equipa total" },
{ name: "Ocupação", value: `${occupancyRate}%`, icon: TrendingUp, trend: "Tempo real" },
];
// 2. Process data for Overview Chart (Last 7 days)
const last7Days = Array.from({ length: 7 }, (_, i) => {
const d = new Date();
d.setDate(d.getDate() - i);
return d.toISOString().split('T')[0];
}).reverse();
const chartData = last7Days.map(date => {
// Usar formato YYYY/MM/DD para compatibilidade total entre browsers
const safeDate = date.replace(/-/g, '/');
const dayLabel = new Date(safeDate).toLocaleDateString('pt-PT', { weekday: 'short' });
const count = reservas.filter(r => r.data === date).length;
return { name: dayLabel, total: count };
});
// 3. Process data for Pie Chart
const pieData = [
{ name: "Livre", value: freeMesas, color: "#2A261E" },
{ name: "Ocupada", value: occupiedMesas, color: "#D4891A" },
{ name: "Reservada", value: reservedMesas, color: "#E8A832" },
];
return (
<div className="space-y-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-3xl font-display font-bold text-foreground">
Bem-vindo, {user?.establishmentName || "Restaurante"}
</h1>
<p className="text-muted-foreground mt-1 text-lg">
Monitorize o desempenho do seu estabelecimento em tempo real.
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map((stat) => (
<Card key={stat.name} className="overflow-hidden border-border/50 shadow-sm hover:shadow-md transition-shadow duration-200">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
{stat.name}
</CardTitle>
<stat.icon className="h-5 w-5 text-primary opacity-80" />
</CardHeader>
<CardContent>
<div className="text-3xl font-display font-bold">
{loadingReservas || loadingMesas ? "..." : stat.value}
</div>
<p className="text-xs text-muted-foreground mt-1 flex items-center gap-1">
<span className="text-primary font-medium">{stat.trend}</span>
</p>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Volume de Reservas</CardTitle>
<CardDescription>Fluxo de clientes nos últimos 7 dias</CardDescription>
</CardHeader>
<CardContent className="pl-2">
<OverviewChart data={chartData} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Ocupação das Mesas</CardTitle>
<CardDescription>Estado atual do restaurante</CardDescription>
</CardHeader>
<CardContent>
<OccupancyPieChart data={pieData} />
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="col-span-1">
<CardHeader>
<CardTitle>Últimas Reservas</CardTitle>
<CardDescription>Atividade mais recente</CardDescription>
</CardHeader>
<CardContent>
{reservas.length > 0 ? (
<div className="space-y-4">
{reservas.slice(0, 5).map((r) => (
<div key={r.id} className="flex items-center justify-between border-b border-border/50 pb-3 last:border-0 last:pb-0">
<div>
<p className="font-medium">{r.clienteEmail}</p>
<p className="text-xs text-muted-foreground">{r.data} às {r.hora} {r.pessoas} pessoas</p>
</div>
<div className={`px-2 py-1 rounded-full text-[10px] font-bold uppercase ${
r.estado.startsWith("Confirmada") ? "bg-green-500/10 text-green-500" :
r.estado === "Pendente" ? "bg-amber-500/10 text-amber-500" :
"bg-muted text-muted-foreground"
}`}>
{r.estado}
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-center text-muted-foreground">
<p>Nenhuma atividade registada.</p>
</div>
)}
</CardContent>
</Card>
<Card className="col-span-1">
<CardHeader>
<CardTitle>Mesas Críticas</CardTitle>
<CardDescription>Mesas que requerem atenção</CardDescription>
</CardHeader>
<CardContent>
{mesas.filter(m => m.estado !== "Livre").length > 0 ? (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{mesas.filter(m => m.estado !== "Livre").map((m) => (
<div key={m.id} className={`flex flex-col items-center justify-center p-3 rounded-lg border transition-colors ${
m.estado === "Ocupada" ? "bg-primary/10 border-primary/30 text-primary shadow-sm" :
"bg-amber-500/10 border-amber-500/30 text-amber-500"
}`}>
<span className="text-xs font-bold uppercase tracking-tighter">Mesa</span>
<span className="text-xl font-display font-bold">{m.numero}</span>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-center text-muted-foreground">
<p>Todas as mesas estão livres.</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import { useReservas } from "@/hooks/useReservas";
import { useMesas } from "@/hooks/useMesas";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CalendarDays, Check, X, Clock, User } from "lucide-react";
import { Reserva } from "@/types/reserva";
import { Mesa } from "@/types/mesa";
import { useToast } from "@/components/ui/toast";
export default function ReservasPage() {
const { reservas, loading, updateReservaEstado, concluirReserva, confirmarComMesa } = useReservas();
const { mesas } = useMesas();
const { toast } = useToast();
const handleConcluir = async (reserva: Reserva) => {
// Tentar encontrar a mesa se o estado for "Confirmada (Mesa X)"
let mesaId: string | undefined;
if (reserva.estado.includes("Mesa")) {
const match = reserva.estado.match(/Mesa (\d+)/);
if (match) {
const num = parseInt(match[1]);
const mesa = mesas.find(m => m.numero === num);
mesaId = mesa?.id;
}
}
const res = await concluirReserva(reserva.id, mesaId);
if (res.success) {
toast("Reserva concluída e mesa libertada.", "success");
}
};
const handleUpdate = async (id: string, estado: any) => {
const res = await updateReservaEstado(id, estado);
if (res.success) {
toast(`Reserva marcada como ${estado}`, "info");
}
};
const getStatusColor = (estado: string) => {
if (estado.startsWith("Confirmada")) return "bg-green-500/10 text-green-500 border-green-500/20";
switch (estado) {
case "Pendente": return "bg-amber-500/10 text-amber-500 border-amber-500/20";
case "Recusada":
case "Cancelada": return "bg-destructive/10 text-destructive border-destructive/20";
default: return "bg-muted text-muted-foreground border-border";
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-display font-bold text-foreground">Gestão de Reservas</h1>
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-card px-3 py-1.5 rounded-full border border-border">
<CalendarDays className="h-4 w-4 text-primary" />
<span>{new Date().toLocaleDateString('pt-PT')}</span>
</div>
</div>
<div className="grid gap-4">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : reservas.length > 0 ? (
reservas.map((reserva) => (
<Card key={reserva.id} className="overflow-hidden border-border/50 hover:border-primary/30 transition-all duration-200 shadow-sm">
<CardContent className="p-0">
<div className="flex flex-col md:flex-row md:items-center p-6 gap-6">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-3">
<div className={`px-2.5 py-0.5 rounded-full text-[11px] font-bold uppercase border ${getStatusColor(reserva.estado)}`}>
{reserva.estado}
</div>
<span className="text-sm text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" /> {reserva.data} às {reserva.hora}
</span>
</div>
<h3 className="text-xl font-bold">{reserva.clienteEmail}</h3>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<User className="h-4 w-4" /> {reserva.pessoas} pessoas
</span>
</div>
</div>
<div className="flex items-center gap-2">
{reserva.estado === "Pendente" && (
<>
<Button
onClick={() => handleUpdate(reserva.id, "Confirmada")}
className="bg-green-600 hover:bg-green-700 text-white"
size="sm"
>
<Check className="h-4 w-4 mr-2" /> Aceitar
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleUpdate(reserva.id, "Recusada")}
>
<X className="h-4 w-4 mr-1" /> Recusar
</Button>
</>
)}
{reserva.estado.startsWith("Confirmada") && (
<Button
onClick={() => handleConcluir(reserva)}
variant="outline"
size="sm"
className="hover:bg-primary/10 hover:text-primary hover:border-primary/30"
>
<Check className="h-4 w-4 mr-2" /> Marcar como Concluída
</Button>
)}
</div>
</div>
</CardContent>
</Card>
))
) : (
<Card className="border-dashed border-2">
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
<CalendarDays className="h-16 w-16 text-muted-foreground/20 mb-4" />
<h3 className="text-xl font-medium">Sem reservas registadas</h3>
<p className="text-muted-foreground max-w-xs mx-auto">
As reservas feitas pelos clientes através da App Android aparecerão aqui em tempo real.
</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Backgrounds */
--bg-primary: #0F0E0C;
--bg-secondary: #1A1814;
--bg-tertiary: #252219;
--bg-hover: #2E2A22;
/* Brand */
--brand-primary: #D4891A;
--brand-secondary: #E8A832;
--brand-muted: #3D2E0F;
/* Status */
--status-pending: #F59E0B;
--status-confirmed: #10B981;
--status-seated: #3B82F6;
--status-completed: #6B7280;
--status-cancelled: #EF4444;
--status-noshow: #8B5CF6;
/* Text */
--text-primary: #F5F0E8;
--text-secondary: #A09880;
--text-muted: #6B6355;
--text-accent: #D4891A;
/* Borders */
--border: #2A261E;
--border-strong: #3D3828;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-body;
}
h1, h2, h3, h4, h5, h6 {
@apply font-display;
}
}

View File

@@ -0,0 +1,46 @@
import React from "react";
import type { Metadata } from "next";
import { Playfair_Display, DM_Sans, DM_Mono } from "next/font/google";
import "./globals.css";
const playfair = Playfair_Display({
subsets: ["latin"],
variable: '--font-display'
});
const dmSans = DM_Sans({
subsets: ["latin"],
variable: '--font-body'
});
const dmMono = DM_Mono({
subsets: ["latin"],
weight: ["400", "500"],
variable: '--font-mono'
});
export const metadata: Metadata = {
title: "ReservaMesa Dashboard",
description: "Dashboard de Gestão de Reservas para Restaurantes",
};
import { AuthProvider } from "@/contexts/AuthContext";
import { ToastProvider } from "@/components/ui/toast";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="pt" className={`${playfair.variable} ${dmSans.variable} ${dmMono.variable}`}>
<body className="min-h-screen bg-background text-foreground">
<ToastProvider>
<AuthProvider>
{children}
</AuthProvider>
</ToastProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View File

@@ -0,0 +1,35 @@
"use client";
import React, { useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!loading) {
if (!user && !pathname.startsWith("/login") && !pathname.startsWith("/register")) {
router.push("/login");
}
}
}, [user, loading, router, pathname]);
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-primary font-display text-2xl animate-pulse">A carregar...</div>
</div>
);
}
// Se não estiver logado e não estiver numa rota pública, não renderiza nada
// (o useEffect vai redirecionar)
if (!user && !pathname.startsWith("/login") && !pathname.startsWith("/register")) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,89 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { X, Users, Table as TableIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Mesa } from "@/types/mesa";
import { Reserva } from "@/types/reserva";
interface AssignTableModalProps {
isOpen: boolean;
onClose: () => void;
reserva: Reserva | null;
mesas: Mesa[];
onAssign: (mesa: Mesa) => void;
}
export function AssignTableModal({ isOpen, onClose, reserva, mesas, onAssign }: AssignTableModalProps) {
if (!reserva) return null;
const mesasDisponiveis = mesas.filter(
(m) => m.estado === "Livre" && m.capacidade >= reserva.pessoas
);
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 z-[60] bg-background/80 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, x: "-50%", y: "-50%" }}
animate={{ opacity: 1, scale: 1, x: "-50%", y: "-50%" }}
exit={{ opacity: 0, scale: 0.95, x: "-50%", y: "-50%" }}
className="fixed left-1/2 top-1/2 z-[70] w-[90%] max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-card p-8 shadow-[0_0_50px_-12px_rgba(0,0,0,0.5)]"
>
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-display font-bold">Atribuir Mesa</h3>
<button onClick={onClose} className="rounded-full p-1 hover:bg-muted">
<X className="h-5 w-5" />
</button>
</div>
<div className="mb-6 rounded-lg bg-muted/50 p-4">
<p className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-1">Reserva de</p>
<p className="font-bold text-lg">{reserva.clienteEmail}</p>
<p className="text-sm text-muted-foreground">{reserva.pessoas} pessoas {reserva.hora}</p>
</div>
<div className="space-y-4">
<p className="text-sm font-medium">Mesas Disponíveis (Capacidade {reserva.pessoas})</p>
<div className="grid grid-cols-2 gap-3 max-h-60 overflow-y-auto pr-2">
{mesasDisponiveis.map((mesa) => (
<button
key={mesa.id}
onClick={() => onAssign(mesa)}
className="flex flex-col items-center justify-center rounded-lg border border-border p-3 hover:border-primary/50 hover:bg-primary/5 transition-all group"
>
<TableIcon className="h-5 w-5 mb-1 text-muted-foreground group-hover:text-primary" />
<span className="font-bold text-lg">Mesa {mesa.numero}</span>
<span className="text-xs text-muted-foreground">{mesa.capacidade} lugares</span>
</button>
))}
</div>
{mesasDisponiveis.length === 0 && (
<div className="text-center py-6 border-2 border-dashed rounded-lg">
<p className="text-sm text-muted-foreground text-destructive">Nenhuma mesa livre com capacidade suficiente.</p>
</div>
)}
</div>
<div className="mt-8 flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => onAssign({ numero: 0 } as any)}>
Confirmar sem Mesa
</Button>
<Button variant="ghost" onClick={onClose}>Cancelar</Button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { UserNav } from "./UserNav";
import { usePathname } from "next/navigation";
const pageTitles: Record<string, string> = {
"/": "Dashboard",
"/reservas": "Reservas",
"/lista-espera": "Lista de Espera",
"/mesas": "Mesas",
"/equipa": "Equipa",
"/historico": "Histórico",
"/configuracoes": "Configurações",
};
export function Header() {
const pathname = usePathname();
const title = pageTitles[pathname] || "Dashboard";
return (
<header className="hidden md:flex h-20 items-center justify-between px-8 border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-30">
<h2 className="text-xl font-display font-bold text-foreground">{title}</h2>
<UserNav />
</header>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import { useState } from "react";
import { Sidebar } from "./Sidebar";
import { Menu, X } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
export function MobileNav() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="md:hidden">
<div className="flex h-16 items-center justify-between border-b border-border bg-card px-4">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold">R</span>
</div>
<span className="text-lg font-display font-bold text-primary">ReservaMesa</span>
</div>
<button
onClick={() => setIsOpen(true)}
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
<Menu className="h-6 w-6" />
</button>
</div>
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsOpen(false)}
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm"
/>
{/* Sidebar */}
<motion.div
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className="fixed inset-y-0 left-0 z-50 w-64 shadow-2xl"
>
<div className="relative h-full w-full">
<button
onClick={() => setIsOpen(false)}
className="absolute right-4 top-4 z-50 rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
<X className="h-6 w-6" />
</button>
<Sidebar />
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useEffect, useRef } from "react";
import { ref, onChildAdded, off, get } from "firebase/database";
import { db } from "@/lib/firebase";
import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/components/ui/toast";
export function NotificationMonitor() {
const { user } = useAuth();
const { toast } = useToast();
const isInitialLoad = useRef(true);
const seenReservas = useRef<Set<string>>(new Set());
useEffect(() => {
if (!user?.email) return;
const reservasRef = ref(db, "reservas");
// Primeiro, marcamos todas as reservas existentes como "vistas"
// para não disparar notificações para o passado
const loadExisting = async () => {
const snapshot = await get(reservasRef);
if (snapshot.exists()) {
const data = snapshot.val();
Object.keys(data).forEach(id => seenReservas.current.add(id));
}
isInitialLoad.current = false;
};
loadExisting();
const unsubscribe = onChildAdded(reservasRef, (snapshot) => {
const id = snapshot.key;
if (!id || seenReservas.current.has(id)) return;
// Adiciona ao set para não repetir se o listener reiniciar
seenReservas.current.add(id);
// Se ainda estivermos no load inicial (do get), ignoramos o toast
if (isInitialLoad.current) return;
const data = snapshot.val();
if (data.restauranteEmail === user.email && data.estado === "Pendente") {
toast(`Nova reserva recebida de ${data.clienteEmail}!`, "info");
}
});
return () => {
off(reservasRef, "child_added", unsubscribe);
};
}, [user?.email, toast]);
return null; // Componente apenas lógico
}

View File

@@ -0,0 +1,48 @@
"use client";
import React from "react";
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts";
interface OccupancyPieChartProps {
data: { name: string; value: number; color: string }[];
}
export function OccupancyPieChart({ data }: OccupancyPieChartProps) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return <div className="h-[300px] w-full flex items-center justify-center bg-muted/10 animate-pulse rounded-lg" />;
return (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{ backgroundColor: "#1A1814", border: "1px solid #2A261E", borderRadius: "8px" }}
itemStyle={{ color: "#fff" }}
/>
<Legend
verticalAlign="bottom"
align="center"
iconType="circle"
formatter={(value) => <span className="text-xs text-muted-foreground">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import React from "react";
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from "recharts";
interface OverviewChartProps {
data: { name: string; total: number }[];
}
export function OverviewChart({ data }: OverviewChartProps) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return <div className="h-[350px] w-full bg-muted/10 animate-pulse rounded-lg" />;
return (
<ResponsiveContainer width="100%" height={350}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="rgba(255,255,255,0.05)" />
<XAxis
dataKey="name"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
contentStyle={{ backgroundColor: "#1A1814", border: "1px solid #2A261E", borderRadius: "8px" }}
itemStyle={{ color: "#D4891A" }}
cursor={{ fill: "rgba(212, 137, 26, 0.05)" }}
/>
<Bar
dataKey="total"
fill="currentColor"
radius={[4, 4, 0, 0]}
className="fill-primary"
/>
</BarChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
CalendarDays,
Table as TableIcon,
History,
Settings,
LogOut,
ListOrdered,
Users
} from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
const navigation = [
{ name: "Dashboard", href: "/", icon: LayoutDashboard },
{ name: "Reservas", href: "/reservas", icon: CalendarDays },
{ name: "Lista de Espera", href: "/lista-espera", icon: ListOrdered },
{ name: "Mesas", href: "/mesas", icon: TableIcon },
{ name: "Equipa", href: "/equipa", icon: Users },
{ name: "Histórico", href: "/historico", icon: History },
{ name: "Configurações", href: "/configuracoes", icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
const { logout } = useAuth();
return (
<div className="flex h-full flex-col bg-card border-r border-border">
<div className="flex h-20 items-center px-6">
<Link href="/" className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-xl">R</span>
</div>
<span className="text-xl font-display font-bold text-primary">ReservaMesa</span>
</Link>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{navigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={cn(
"group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<item.icon className={cn(
"h-5 w-5",
isActive ? "text-primary" : "text-muted-foreground group-hover:text-accent-foreground"
)} />
{item.name}
</Link>
);
})}
</nav>
<div className="p-4 border-t border-border">
<button
onClick={() => logout()}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-all duration-200"
>
<LogOut className="h-5 w-5" />
Terminar Sessão
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { useAuth } from "@/hooks/useAuth";
import { User } from "lucide-react";
export function UserNav() {
const { user } = useAuth();
return (
<div className="flex items-center gap-3">
<div className="hidden text-right md:block">
<p className="text-sm font-medium text-foreground">{user?.ownerName || "Administrador"}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
</div>
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center border border-primary/20">
<User className="h-5 w-5 text-primary" />
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,75 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-display text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,25 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,39 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
interface SwitchProps extends React.InputHTMLAttributes<HTMLInputElement> {
onCheckedChange?: (checked: boolean) => void;
}
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
({ className, onCheckedChange, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (onCheckedChange) {
onCheckedChange(e.target.checked);
}
};
return (
<div className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
ref={ref}
onChange={handleChange}
{...props}
/>
<div
className={cn(
"w-11 h-6 bg-muted peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary",
className
)}
></div>
</div>
);
}
);
Switch.displayName = "Switch";
export { Switch };

View File

@@ -0,0 +1,75 @@
"use client";
import React, { useState, useEffect, createContext, useContext, useCallback } from "react";
import { X, CheckCircle2, AlertCircle, Info } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
type ToastType = "success" | "error" | "info";
interface Toast {
id: string;
message: string;
type: ToastType;
}
interface ToastContextType {
toast: (message: string, type?: ToastType) => void;
}
const ToastContext = createContext<ToastContextType>({
toast: () => {},
});
export const useToast = () => useContext(ToastContext);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const toast = React.useCallback((message: string, type: ToastType = "info") => {
const id = Math.random().toString(36).substring(2, 9);
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 5000);
}, []);
const removeToast = (id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
};
return (
<ToastContext.Provider value={{ toast }}>
{children}
<div className="fixed bottom-6 right-6 z-50 flex flex-col gap-3 pointer-events-none">
<AnimatePresence>
{toasts.map((t) => (
<motion.div
key={t.id}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }}
className={`pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-xl border shadow-lg backdrop-blur-md min-w-[300px] ${
t.type === "success" ? "bg-green-500/10 border-green-500/20 text-green-500" :
t.type === "error" ? "bg-destructive/10 border-destructive/20 text-destructive" :
"bg-primary/10 border-primary/20 text-primary"
}`}
>
{t.type === "success" && <CheckCircle2 className="h-5 w-5 shrink-0" />}
{t.type === "error" && <AlertCircle className="h-5 w-5 shrink-0" />}
{t.type === "info" && <Info className="h-5 w-5 shrink-0" />}
<p className="text-sm font-medium flex-1">{t.message}</p>
<button
onClick={() => removeToast(t.id)}
className="p-1 rounded-md hover:bg-black/5 transition-colors"
>
<X className="h-4 w-4 opacity-50" />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</ToastContext.Provider>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import React, { createContext, useContext, useEffect, useState } from "react";
import { onAuthStateChanged, User as FirebaseUser, signOut } from "firebase/auth";
import { ref, get, update } from "firebase/database";
import { auth, db } from "@/lib/firebase";
import { RestaurantUser } from "@/types/user";
import { useRouter } from "next/navigation";
interface AuthContextType {
user: RestaurantUser | null;
firebaseUser: FirebaseUser | null;
loading: boolean;
logout: () => Promise<void>;
updateRestaurantProfile: (updates: Partial<RestaurantUser>) => Promise<{ success: boolean; error?: any }>;
}
const AuthContext = createContext<AuthContextType>({
user: null,
firebaseUser: null,
loading: true,
logout: async () => {},
updateRestaurantProfile: async () => ({ success: false }),
});
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const [user, setUser] = useState<RestaurantUser | null>(null);
const [firebaseUser, setFirebaseUser] = useState<FirebaseUser | null>(null);
const [loading, setLoading] = useState(true);
const buildDocumentId = (email: string) => {
return email.replace(/\./g, "_").replace(/@/g, "_at_");
};
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, async (currentUser) => {
try {
if (currentUser && currentUser.email) {
setFirebaseUser(currentUser);
const docId = buildDocumentId(currentUser.email);
const userRef = ref(db, `Restaurantes/${docId}`);
const snapshot = await get(userRef);
if (snapshot.exists()) {
const data = snapshot.val() as RestaurantUser;
if (data.accountType === "ESTABELECIMENTO") {
setUser(data);
} else {
console.warn("User is not a restaurant");
setUser(null);
await signOut(auth);
}
} else {
console.warn("User record not found in Restaurantes");
setUser(null);
}
} else {
setFirebaseUser(null);
setUser(null);
}
} catch (error) {
console.error("Auth initialization error:", error);
} finally {
setLoading(false);
}
});
return () => unsubscribe();
}, []);
const logout = async () => {
try {
await signOut(auth);
router.push("/login");
} catch (error) {
console.error("Erro ao fazer logout:", error);
}
};
const updateRestaurantProfile = async (updates: Partial<RestaurantUser>) => {
if (!firebaseUser?.email) return { success: false, error: "Utilizador não autenticado" };
try {
const docId = buildDocumentId(firebaseUser.email);
const userRef = ref(db, `Restaurantes/${docId}`);
await update(userRef, updates);
setUser(prev => prev ? { ...prev, ...updates } : null);
return { success: true };
} catch (error) {
console.error("Erro ao atualizar perfil:", error);
return { success: false, error };
}
};
return (
<AuthContext.Provider value={{ user, firebaseUser, loading, logout, updateRestaurantProfile }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);

View File

@@ -0,0 +1,3 @@
import { useAuth } from "@/contexts/AuthContext";
export { useAuth };

View File

@@ -0,0 +1,94 @@
"use client";
import { useState, useEffect } from "react";
import { ref, onValue, off, update, push, remove } from "firebase/database";
import { db } from "@/lib/firebase";
import { useAuth } from "@/hooks/useAuth";
import { Mesa, MesaEstado } from "@/types/mesa";
export function useMesas() {
const { user } = useAuth();
const [mesas, setMesas] = useState<Mesa[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user?.email) return;
const mesasRef = ref(db, "Mesas");
const unsubscribe = onValue(mesasRef, (snapshot) => {
const data = snapshot.val();
const list: Mesa[] = [];
if (data) {
Object.keys(data).forEach((key) => {
const item = data[key];
if (item.restauranteEmail === user.email) {
list.push({
id: key,
...item
});
}
});
}
// Sort by table number
list.sort((a, b) => a.numero - b.numero);
setMesas(list);
setLoading(false);
});
return () => off(mesasRef, "value", unsubscribe);
}, [user?.email]);
const updateMesaEstado = async (mesaId: string, novoEstado: MesaEstado) => {
try {
await update(ref(db, `Mesas/${mesaId}`), {
estado: novoEstado
});
return { success: true };
} catch (error) {
console.error("Erro ao atualizar estado da mesa:", error);
return { success: false, error };
}
};
const addMesa = async (numero: number, capacidade: number) => {
if (!user?.email) return { success: false, error: "Utilizador não autenticado" };
try {
const newMesaRef = push(ref(db, "Mesas"));
const mesaData: Mesa = {
id: newMesaRef.key as string,
numero,
capacidade,
estado: "Livre",
restauranteEmail: user.email
};
await update(newMesaRef, mesaData);
return { success: true };
} catch (error) {
console.error("Erro ao adicionar mesa:", error);
return { success: false, error };
}
};
const deleteMesa = async (mesaId: string) => {
try {
await remove(ref(db, `Mesas/${mesaId}`));
return { success: true };
} catch (error) {
console.error("Erro ao remover mesa:", error);
return { success: false, error };
}
};
return {
mesas,
loading,
updateMesaEstado,
addMesa,
deleteMesa
};
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useState, useEffect } from "react";
import { ref, onValue, off, update } from "firebase/database";
import { db } from "@/lib/firebase";
import { useAuth } from "@/hooks/useAuth";
import { Reserva, ReservaEstado } from "@/types/reserva";
export function useReservas() {
const { user } = useAuth();
const [reservas, setReservas] = useState<Reserva[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user?.email) return;
const reservasRef = ref(db, "reservas");
const unsubscribe = onValue(reservasRef, (snapshot) => {
const data = snapshot.val();
const list: Reserva[] = [];
if (data) {
Object.keys(data).forEach((key) => {
const item = data[key];
if (item.restauranteEmail === user.email) {
list.push({
id: key,
...item
});
}
});
}
// Sort by date and time (newest first for management)
list.sort((a, b) => {
const dateA = new Date(`${a.data.replace(/-/g, "/")} ${a.hora}`);
const dateB = new Date(`${b.data.replace(/-/g, "/")} ${b.hora}`);
return dateB.getTime() - dateA.getTime();
});
setReservas(list);
setLoading(false);
});
return () => off(reservasRef, "value", unsubscribe);
}, [user?.email]);
const updateReservaEstado = async (reservaId: string, novoEstado: ReservaEstado) => {
try {
await update(ref(db, `reservas/${reservaId}`), {
estado: novoEstado
});
return { success: true };
} catch (error) {
console.error("Erro ao atualizar estado da reserva:", error);
return { success: false, error };
}
};
const confirmarComMesa = async (reservaId: string, mesaId: string, mesaNumero: number) => {
try {
const updates: any = {};
updates[`reservas/${reservaId}/estado`] = `Confirmada (Mesa ${mesaNumero})`;
if (mesaId) {
updates[`Mesas/${mesaId}/estado`] = "Reservada";
}
await update(ref(db), updates);
return { success: true };
} catch (error) {
console.error("Erro ao confirmar reserva com mesa:", error);
return { success: false, error };
}
};
const concluirReserva = async (reservaId: string, mesaId?: string) => {
try {
const updates: any = {};
updates[`reservas/${reservaId}/estado`] = "Concluída";
if (mesaId) {
updates[`Mesas/${mesaId}/estado`] = "Livre";
}
await update(ref(db), updates);
return { success: true };
} catch (error) {
console.error("Erro ao concluir reserva:", error);
return { success: false, error };
}
};
return {
reservas,
loading,
updateReservaEstado,
confirmarComMesa,
concluirReserva
};
}

View File

@@ -0,0 +1,76 @@
"use client";
import { useState, useEffect } from "react";
import { ref, onValue, off, update, push, remove } from "firebase/database";
import { db } from "@/lib/firebase";
import { useAuth } from "@/hooks/useAuth";
import { Staff } from "@/types/staff";
export function useStaff() {
const { user } = useAuth();
const [staff, setStaff] = useState<Staff[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user?.email) return;
const staffRef = ref(db, "Staff");
const unsubscribe = onValue(staffRef, (snapshot) => {
const data = snapshot.val();
const list: Staff[] = [];
if (data) {
Object.keys(data).forEach((key) => {
const item = data[key];
if (item.restauranteEmail === user.email) {
list.push({
id: key,
...item
});
}
});
}
setStaff(list);
setLoading(false);
});
return () => off(staffRef, "value", unsubscribe);
}, [user?.email]);
const addStaff = async (member: Omit<Staff, "id" | "restauranteEmail">) => {
if (!user?.email) return { success: false, error: "Utilizador não autenticado" };
try {
const newStaffRef = push(ref(db, "Staff"));
const staffData: Staff = {
id: newStaffRef.key as string,
...member,
restauranteEmail: user.email
};
await update(newStaffRef, staffData);
return { success: true };
} catch (error) {
console.error("Erro ao adicionar funcionário:", error);
return { success: false, error };
}
};
const deleteStaff = async (staffId: string) => {
try {
await remove(ref(db, `Staff/${staffId}`));
return { success: true };
} catch (error) {
console.error("Erro ao remover funcionário:", error);
return { success: false, error };
}
};
return {
staff,
loading,
addStaff,
deleteStaff
};
}

View File

@@ -0,0 +1,23 @@
import { initializeApp, getApps } from "firebase/app";
import { getAuth } from "firebase/auth";
import { getDatabase } from "firebase/database";
// As variáveis de ambiente devem ser configuradas no Vercel e no ficheiro .env.local
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY || "AIzaSyCPz7Pd3tJj3QkF7fV_vudCJythNsyR57k",
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN || "namesa-429c1.firebaseapp.com",
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID || "namesa-429c1",
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET || "namesa-429c1.firebasestorage.app",
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID || "476421715902",
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID || "1:476421715902:web:placeholder", // placeholder needed for web client SDK
// Nota importante: Como verificado na codebase Android,
// O ReservaMesa usa Realtime Database e não Firestore.
databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL || "https://namesa-429c1-default-rtdb.firebaseio.com"
};
// Initialize Firebase only if there are no apps initialized yet
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
export const auth = getAuth(app);
export const db = getDatabase(app);
export default app;

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// Configuração básica do middleware. A proteção real de rotas
// será implementada na Fase 3 (Autenticação).
return NextResponse.next()
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}

5
reserva-mesa-dashboard/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
module.exports = nextConfig;

7565
reserva-mesa-dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
{
"name": "reserva-mesa-dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^3.3.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"firebase": "^10.12.0",
"framer-motion": "^11.1.7",
"lucide-react": "^0.378.0",
"next": "14.2.3",
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.51.4",
"recharts": "^2.12.7",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2",
"zod": "^3.23.6"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.5.0",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,85 @@
import type { Config } from "tailwindcss"
const config = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "var(--border)",
input: "var(--bg-tertiary)",
ring: "var(--brand-primary)",
background: "var(--bg-primary)",
foreground: "var(--text-primary)",
primary: {
DEFAULT: "var(--brand-primary)",
foreground: "var(--bg-primary)",
},
secondary: {
DEFAULT: "var(--bg-secondary)",
foreground: "var(--text-primary)",
},
destructive: {
DEFAULT: "var(--status-cancelled)",
foreground: "var(--text-primary)",
},
muted: {
DEFAULT: "var(--bg-hover)",
foreground: "var(--text-muted)",
},
accent: {
DEFAULT: "var(--brand-secondary)",
foreground: "var(--bg-primary)",
},
popover: {
DEFAULT: "var(--bg-secondary)",
foreground: "var(--text-primary)",
},
card: {
DEFAULT: "var(--bg-secondary)",
foreground: "var(--text-primary)",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
display: ['var(--font-display)'],
body: ['var(--font-body)'],
mono: ['var(--font-mono)'],
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config
export default config

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,9 @@
export type MesaEstado = "Livre" | "Ocupada" | "Reservada";
export interface Mesa {
id: string;
numero: number;
capacidade: number;
estado: MesaEstado;
restauranteEmail: string;
}

View File

@@ -0,0 +1,12 @@
export type ReservaEstado = "Pendente" | "Confirmada" | "Concluída" | "Cancelada" | "Recusada";
export interface Reserva {
id: string;
clienteEmail: string;
restauranteName: string;
restauranteEmail: string;
data: string;
hora: string;
pessoas: number;
estado: ReservaEstado;
}

View File

@@ -0,0 +1,8 @@
export interface Staff {
id: string;
name: string;
role: string;
email: string;
phoneNumber?: string;
restauranteEmail: string;
}

View File

@@ -0,0 +1,18 @@
export interface RestaurantUser {
uid: string;
email: string;
displayName: string;
role: 'ADMIN' | 'CLIENTE';
accountType: 'ESTABELECIMENTO' | 'CLIENTE';
createdAt: number;
ownerName?: string;
ownerEmail?: string;
ownerPhone?: string;
establishmentName?: string;
establishmentEmail?: string;
establishmentPhone?: string;
category?: string;
phoneNumber?: string;
address?: string;
isAvailable?: boolean;
}