chore: add project files and setup gitignore
This commit is contained in:
60
.gitignore
vendored
Normal file
60
.gitignore
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Android Specific
|
||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/*/build
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.dex
|
||||||
|
*.class
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Firebase
|
||||||
|
google-services.json
|
||||||
|
|
||||||
|
# Node / Next.js
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
/.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
/captures/
|
||||||
|
|
||||||
|
# OS specific
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.swp
|
||||||
|
*.log
|
||||||
|
.vscode/
|
||||||
76
docs/01_PROJECT_OVERVIEW.md
Normal file
76
docs/01_PROJECT_OVERVIEW.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# 🍽️ ReservaMesa — Documentação do Projeto
|
||||||
|
|
||||||
|
## Visão Geral
|
||||||
|
|
||||||
|
**ReservaMesa** é um painel web (dashboard) desenvolvido para restaurantes gerirem as suas reservas de mesa de forma eficiente. Funciona como o **backend visual** de uma aplicação mobile já existente que permite aos clientes reservarem mesas em restaurantes.
|
||||||
|
|
||||||
|
### Problema que resolve
|
||||||
|
- Filas de espera nos restaurantes → eliminadas por reservas antecipadas
|
||||||
|
- Gestão caótica de mesas → centralizada num dashboard
|
||||||
|
- Clientes sem visibilidade do espaço disponível → reserva com confirmação imediata
|
||||||
|
- Restaurantes com capacidade subutilizada → otimização por slot de tempo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objetivos do Website
|
||||||
|
|
||||||
|
| Objetivo | Descrição |
|
||||||
|
|---|---|
|
||||||
|
| **Dashboard de Gestão** | Interface para o restaurante ver todas as reservas em tempo real |
|
||||||
|
| **Gestão de Mesas** | Configurar número de mesas, capacidade, zonas (interior/exterior/VIP) |
|
||||||
|
| **Calendário de Reservas** | Visualização diária, semanal e por turno |
|
||||||
|
| **Notificações** | Alertas de novas reservas, cancelamentos, no-shows |
|
||||||
|
| **Relatórios** | Ocupação média, horários de pico, receita estimada |
|
||||||
|
| **Autenticação** | Login seguro para cada restaurante com Firebase Auth |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stakeholders
|
||||||
|
|
||||||
|
| Papel | Utilizador | Necessidade |
|
||||||
|
|---|---|---|
|
||||||
|
| **Gestor de Restaurante** | Acede ao dashboard | Ver e gerir reservas do dia |
|
||||||
|
| **Staff (Maître)** | Tablet na recepção | Check-in de clientes |
|
||||||
|
| **Cliente Final** | App mobile (existente) | Fazer reserva, receber confirmação |
|
||||||
|
| **Admin do Sistema** | Super-admin | Gerir restaurantes na plataforma |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integração com App Mobile
|
||||||
|
|
||||||
|
O website consome a **mesma base de dados Firebase** que a aplicação mobile:
|
||||||
|
- Dados partilhados em tempo real via Firestore
|
||||||
|
- Autenticação unificada via Firebase Auth
|
||||||
|
- Storage de imagens via Firebase Storage
|
||||||
|
- Sem duplicação de dados — única fonte de verdade
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fases do Projeto
|
||||||
|
|
||||||
|
| Fase | Nome | Duração Estimada | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Planeamento & Documentação | 1 semana | ✅ Concluído |
|
||||||
|
| 2 | Setup & Estrutura Base | 3 dias | ⏳ A iniciar |
|
||||||
|
| 3 | Autenticação & Onboarding | 3 dias | 🔜 Pendente |
|
||||||
|
| 4 | Dashboard Principal | 1 semana | 🔜 Pendente |
|
||||||
|
| 5 | Gestão de Reservas | 1 semana | 🔜 Pendente |
|
||||||
|
| 6 | Gestão de Mesas | 4 dias | 🔜 Pendente |
|
||||||
|
| 7 | Relatórios & Analytics | 4 dias | 🔜 Pendente |
|
||||||
|
| 8 | Notificações & Real-time | 3 dias | 🔜 Pendente |
|
||||||
|
| 9 | Testes & Deploy | 1 semana | 🔜 Pendente |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisões de Arquitetura
|
||||||
|
|
||||||
|
1. **Next.js** como framework principal — SSR para SEO, routing file-based, API routes
|
||||||
|
2. **Firebase SDK** no frontend — listeners em tempo real sem servidor intermediário
|
||||||
|
3. **Tailwind CSS** — design system consistente e rápido de implementar
|
||||||
|
4. **Shadcn/UI** — componentes acessíveis e customizáveis
|
||||||
|
5. **Vercel** — deploy automático com cada push
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Documento gerado em: Maio 2026*
|
||||||
|
*Versão: 1.0.0*
|
||||||
179
docs/02_TECH_STACK.md
Normal file
179
docs/02_TECH_STACK.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# 🛠️ Stack Tecnológica — ReservaMesa
|
||||||
|
|
||||||
|
## Stack Principal
|
||||||
|
|
||||||
|
### Frontend Framework
|
||||||
|
**Next.js 14** (App Router)
|
||||||
|
- Server Components para performance
|
||||||
|
- Client Components para interatividade em tempo real
|
||||||
|
- API Routes para webhooks e integrações externas
|
||||||
|
- Middleware para proteção de rotas
|
||||||
|
|
||||||
|
### Linguagem
|
||||||
|
**TypeScript** — tipagem estática, melhor DX, menos bugs em produção
|
||||||
|
|
||||||
|
### Estilização
|
||||||
|
**Tailwind CSS v3** + **Shadcn/UI**
|
||||||
|
- Design system consistente
|
||||||
|
- Componentes como: Calendar, Table, Dialog, Toast, Tabs
|
||||||
|
- Dark/Light mode nativo
|
||||||
|
|
||||||
|
### Animações
|
||||||
|
**Framer Motion** — transições de página, animações de estado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Firebase (Base de Dados Existente)
|
||||||
|
|
||||||
|
### Serviços Utilizados
|
||||||
|
| Serviço | Uso no Projeto |
|
||||||
|
|---|---|
|
||||||
|
| **Firestore** | Base de dados principal (reservas, mesas, restaurantes) |
|
||||||
|
| **Firebase Auth** | Autenticação de restaurantes |
|
||||||
|
| **Firebase Storage** | Imagens de restaurantes, logos |
|
||||||
|
| **Firebase Functions** | Triggers automáticos (confirmação de reserva, notificações) |
|
||||||
|
| **Firebase Hosting** | (opcional, substituível por Vercel) |
|
||||||
|
|
||||||
|
### Estrutura do Firestore (Proposta)
|
||||||
|
|
||||||
|
```
|
||||||
|
/restaurants/{restaurantId}
|
||||||
|
- name: string
|
||||||
|
- email: string
|
||||||
|
- phone: string
|
||||||
|
- address: string
|
||||||
|
- capacity: number
|
||||||
|
- openingHours: map
|
||||||
|
- logoUrl: string
|
||||||
|
- settings: map
|
||||||
|
|
||||||
|
/restaurants/{restaurantId}/tables/{tableId}
|
||||||
|
- number: number
|
||||||
|
- capacity: number
|
||||||
|
- zone: "interior" | "exterior" | "vip"
|
||||||
|
- isActive: boolean
|
||||||
|
|
||||||
|
/restaurants/{restaurantId}/reservations/{reservationId}
|
||||||
|
- clientName: string
|
||||||
|
- clientPhone: string
|
||||||
|
- clientEmail: string
|
||||||
|
- date: timestamp
|
||||||
|
- timeSlot: string (ex: "19:00-21:00")
|
||||||
|
- partySize: number
|
||||||
|
- tableId: string (ref)
|
||||||
|
- status: "pending" | "confirmed" | "seated" | "completed" | "cancelled" | "no-show"
|
||||||
|
- notes: string
|
||||||
|
- createdAt: timestamp
|
||||||
|
- updatedAt: timestamp
|
||||||
|
|
||||||
|
/restaurants/{restaurantId}/timeslots/{slotId}
|
||||||
|
- time: string
|
||||||
|
- duration: number (minutos)
|
||||||
|
- maxCapacity: number
|
||||||
|
- isActive: boolean
|
||||||
|
|
||||||
|
/users/{userId}
|
||||||
|
- restaurantId: string
|
||||||
|
- role: "owner" | "manager" | "staff"
|
||||||
|
- name: string
|
||||||
|
- email: string
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Real-Time Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
App Mobile (Cliente) ──┐
|
||||||
|
├──▶ Firestore ◀──▶ Dashboard Web (Restaurante)
|
||||||
|
Admin Dashboard ────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- `onSnapshot()` listeners para atualizações em tempo real
|
||||||
|
- Sem necessidade de polling ou WebSockets adicionais
|
||||||
|
- Latência < 500ms para atualizações
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deploy & CI/CD
|
||||||
|
|
||||||
|
| Ferramenta | Papel |
|
||||||
|
|---|---|
|
||||||
|
| **Vercel** | Hosting + Deploy automático |
|
||||||
|
| **GitHub** | Repositório de código |
|
||||||
|
| **GitHub Actions** | CI: lint, type-check, testes |
|
||||||
|
| **Firebase CLI** | Deploy de Functions e Rules |
|
||||||
|
|
||||||
|
### Variáveis de Ambiente Necessárias
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_FIREBASE_API_KEY=
|
||||||
|
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
|
||||||
|
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
|
||||||
|
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
|
||||||
|
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
|
||||||
|
NEXT_PUBLIC_FIREBASE_APP_ID=
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Segurança — Firebase Rules
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
rules_version = '2';
|
||||||
|
service cloud.firestore {
|
||||||
|
match /databases/{database}/documents {
|
||||||
|
|
||||||
|
// Restaurante só acede aos seus próprios dados
|
||||||
|
match /restaurants/{restaurantId} {
|
||||||
|
allow read, write: if request.auth != null
|
||||||
|
&& request.auth.uid == restaurantId;
|
||||||
|
|
||||||
|
match /reservations/{reservationId} {
|
||||||
|
allow read, write: if request.auth != null
|
||||||
|
&& get(/databases/$(database)/documents/users/$(request.auth.uid))
|
||||||
|
.data.restaurantId == restaurantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
match /tables/{tableId} {
|
||||||
|
allow read, write: if request.auth != null
|
||||||
|
&& get(/databases/$(database)/documents/users/$(request.auth.uid))
|
||||||
|
.data.restaurantId == restaurantId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// App mobile pode criar reservas e ler disponibilidade
|
||||||
|
match /restaurants/{restaurantId}/reservations/{reservationId} {
|
||||||
|
allow create: if request.auth != null;
|
||||||
|
allow read: if request.auth != null
|
||||||
|
&& resource.data.clientId == request.auth.uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependências Principais
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"next": "14.x",
|
||||||
|
"react": "18.x",
|
||||||
|
"typescript": "5.x",
|
||||||
|
"firebase": "10.x",
|
||||||
|
"tailwindcss": "3.x",
|
||||||
|
"@shadcn/ui": "latest",
|
||||||
|
"framer-motion": "11.x",
|
||||||
|
"date-fns": "3.x",
|
||||||
|
"recharts": "2.x",
|
||||||
|
"react-hook-form": "7.x",
|
||||||
|
"zod": "3.x",
|
||||||
|
"lucide-react": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Versão: 1.0.0 | Maio 2026*
|
||||||
168
docs/03_PROJECT_STRUCTURE.md
Normal file
168
docs/03_PROJECT_STRUCTURE.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# 📁 Estrutura do Projeto — ReservaMesa
|
||||||
|
|
||||||
|
## Estrutura de Pastas
|
||||||
|
|
||||||
|
```
|
||||||
|
reserva-mesa-dashboard/
|
||||||
|
│
|
||||||
|
├── 📁 app/ # Next.js App Router
|
||||||
|
│ ├── 📁 (auth)/ # Route group — não autenticado
|
||||||
|
│ │ ├── login/
|
||||||
|
│ │ │ └── page.tsx # Página de login
|
||||||
|
│ │ └── register/
|
||||||
|
│ │ └── page.tsx # Onboarding do restaurante
|
||||||
|
│ │
|
||||||
|
│ ├── 📁 (dashboard)/ # Route group — autenticado
|
||||||
|
│ │ ├── layout.tsx # Layout com sidebar
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── page.tsx # Dashboard principal (/)
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── 📁 reservations/
|
||||||
|
│ │ │ ├── page.tsx # Lista de reservas
|
||||||
|
│ │ │ ├── [id]/
|
||||||
|
│ │ │ │ └── page.tsx # Detalhe da reserva
|
||||||
|
│ │ │ └── new/
|
||||||
|
│ │ │ └── page.tsx # Nova reserva manual
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── 📁 tables/
|
||||||
|
│ │ │ ├── page.tsx # Gestão de mesas (mapa visual)
|
||||||
|
│ │ │ └── settings/
|
||||||
|
│ │ │ └── page.tsx # Configurar mesas
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── 📁 calendar/
|
||||||
|
│ │ │ └── page.tsx # Vista calendário
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── 📁 analytics/
|
||||||
|
│ │ │ └── page.tsx # Relatórios e estatísticas
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── 📁 settings/
|
||||||
|
│ │ │ ├── page.tsx # Definições do restaurante
|
||||||
|
│ │ │ ├── timeslots/
|
||||||
|
│ │ │ │ └── page.tsx # Configurar slots de tempo
|
||||||
|
│ │ │ └── notifications/
|
||||||
|
│ │ │ └── page.tsx # Preferências de notificação
|
||||||
|
│ │ │
|
||||||
|
│ │ └── 📁 staff/
|
||||||
|
│ │ └── page.tsx # Gestão de funcionários
|
||||||
|
│ │
|
||||||
|
│ ├── 📁 api/ # API Routes Next.js
|
||||||
|
│ │ ├── webhooks/
|
||||||
|
│ │ │ └── route.ts # Webhooks externos
|
||||||
|
│ │ └── export/
|
||||||
|
│ │ └── route.ts # Export de dados (CSV/PDF)
|
||||||
|
│ │
|
||||||
|
│ ├── layout.tsx # Root layout
|
||||||
|
│ ├── globals.css # Estilos globais
|
||||||
|
│ └── not-found.tsx
|
||||||
|
│
|
||||||
|
├── 📁 components/
|
||||||
|
│ ├── 📁 ui/ # Shadcn/UI components
|
||||||
|
│ │ ├── button.tsx
|
||||||
|
│ │ ├── calendar.tsx
|
||||||
|
│ │ ├── dialog.tsx
|
||||||
|
│ │ └── ...
|
||||||
|
│ │
|
||||||
|
│ ├── 📁 layout/
|
||||||
|
│ │ ├── Sidebar.tsx # Navegação lateral
|
||||||
|
│ │ ├── Header.tsx # Topbar com notificações
|
||||||
|
│ │ └── MobileNav.tsx # Navegação mobile
|
||||||
|
│ │
|
||||||
|
│ ├── 📁 dashboard/
|
||||||
|
│ │ ├── StatsCards.tsx # Cards de métricas do dia
|
||||||
|
│ │ ├── TodayTimeline.tsx # Timeline de reservas hoje
|
||||||
|
│ │ ├── QuickActions.tsx # Ações rápidas
|
||||||
|
│ │ └── RecentActivity.tsx # Feed de atividade recente
|
||||||
|
│ │
|
||||||
|
│ ├── 📁 reservations/
|
||||||
|
│ │ ├── ReservationCard.tsx # Card individual de reserva
|
||||||
|
│ │ ├── ReservationTable.tsx # Tabela de reservas
|
||||||
|
│ │ ├── ReservationForm.tsx # Formulário criar/editar
|
||||||
|
│ │ ├── StatusBadge.tsx # Badge de status colorido
|
||||||
|
│ │ └── ReservationFilters.tsx # Filtros de pesquisa
|
||||||
|
│ │
|
||||||
|
│ ├── 📁 tables/
|
||||||
|
│ │ ├── FloorPlan.tsx # Mapa visual do restaurante
|
||||||
|
│ │ ├── TableCard.tsx # Card de mesa individual
|
||||||
|
│ │ └── TableEditor.tsx # Editor de disposição
|
||||||
|
│ │
|
||||||
|
│ └── 📁 analytics/
|
||||||
|
│ ├── OccupancyChart.tsx # Gráfico de ocupação
|
||||||
|
│ ├── RevenueChart.tsx # Gráfico de receita estimada
|
||||||
|
│ └── PeakHoursChart.tsx # Horários de pico
|
||||||
|
│
|
||||||
|
├── 📁 lib/
|
||||||
|
│ ├── firebase.ts # Inicialização Firebase
|
||||||
|
│ ├── firestore.ts # Helpers Firestore
|
||||||
|
│ ├── auth.ts # Helpers Auth
|
||||||
|
│ └── utils.ts # Utilitários gerais
|
||||||
|
│
|
||||||
|
├── 📁 hooks/
|
||||||
|
│ ├── useReservations.ts # Hook para reservas em RT
|
||||||
|
│ ├── useTables.ts # Hook para mesas em RT
|
||||||
|
│ ├── useRestaurant.ts # Hook dados do restaurante
|
||||||
|
│ └── useNotifications.ts # Hook notificações
|
||||||
|
│
|
||||||
|
├── 📁 store/
|
||||||
|
│ └── useAppStore.ts # Zustand store global
|
||||||
|
│
|
||||||
|
├── 📁 types/
|
||||||
|
│ ├── reservation.ts # Tipos de reserva
|
||||||
|
│ ├── table.ts # Tipos de mesa
|
||||||
|
│ ├── restaurant.ts # Tipos de restaurante
|
||||||
|
│ └── user.ts # Tipos de utilizador
|
||||||
|
│
|
||||||
|
├── 📁 constants/
|
||||||
|
│ └── index.ts # Constantes (status, zones, etc.)
|
||||||
|
│
|
||||||
|
├── middleware.ts # Proteção de rotas Auth
|
||||||
|
├── next.config.js
|
||||||
|
├── tailwind.config.js
|
||||||
|
├── tsconfig.json
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Páginas e Funcionalidades
|
||||||
|
|
||||||
|
### 🏠 Dashboard (`/`)
|
||||||
|
- Total de reservas hoje
|
||||||
|
- Ocupação atual (% de mesas ocupadas)
|
||||||
|
- Próximas chegadas (próximas 2h)
|
||||||
|
- Alertas: no-shows, cancelamentos recentes
|
||||||
|
- Quick actions: Nova reserva, Check-in rápido
|
||||||
|
|
||||||
|
### 📋 Reservas (`/reservations`)
|
||||||
|
- Lista com filtros: data, status, zona, tamanho do grupo
|
||||||
|
- Pesquisa por nome/telefone do cliente
|
||||||
|
- Acções inline: confirmar, sentar, cancelar, no-show
|
||||||
|
- Criar reserva manual (para reservas por telefone)
|
||||||
|
|
||||||
|
### 🗺️ Mesas (`/tables`)
|
||||||
|
- Mapa visual drag-and-drop do restaurante
|
||||||
|
- Estado visual: livre (verde), reservada (amarelo), ocupada (vermelho)
|
||||||
|
- Clicar numa mesa abre o painel com reserva atual
|
||||||
|
- Modo de edição para reconfigurar layout
|
||||||
|
|
||||||
|
### 📅 Calendário (`/calendar`)
|
||||||
|
- Vista mensal/semanal/diária
|
||||||
|
- Blocos coloridos por zona
|
||||||
|
- Arrastar para mover reservas (com validação de conflitos)
|
||||||
|
|
||||||
|
### 📊 Analytics (`/analytics`)
|
||||||
|
- Taxa de ocupação por dia/semana/mês
|
||||||
|
- Horários de maior procura
|
||||||
|
- Taxa de cancelamentos e no-shows
|
||||||
|
- Tempo médio de ocupação de mesa
|
||||||
|
- Export CSV
|
||||||
|
|
||||||
|
### ⚙️ Configurações (`/settings`)
|
||||||
|
- Dados do restaurante (nome, morada, contacto, logo)
|
||||||
|
- Horário de funcionamento
|
||||||
|
- Slots de tempo disponíveis para reserva
|
||||||
|
- Capacidade máxima por slot
|
||||||
|
- Notificações (email, SMS — integrações futuras)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Versão: 1.0.0 | Maio 2026*
|
||||||
203
docs/04_DESIGN_SYSTEM.md
Normal file
203
docs/04_DESIGN_SYSTEM.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# 🎨 Design System — ReservaMesa
|
||||||
|
|
||||||
|
## Identidade Visual
|
||||||
|
|
||||||
|
### Conceito
|
||||||
|
**"Elegância Operacional"** — Um dashboard que parece profissional e de alta confiança, como um sistema de gestão de um restaurante Michelin. Cores quentes (não o típico azul SaaS), tipografia editorial, e uma sensação de ambiente noturno de restaurante premium.
|
||||||
|
|
||||||
|
### Tom e Personalidade
|
||||||
|
- Profissional, mas com calor humano
|
||||||
|
- Moderno sem ser frio
|
||||||
|
- Eficiente mas confortável para uso prolongado
|
||||||
|
- Inspirado na estética de restaurantes de autor europeus
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paleta de Cores
|
||||||
|
|
||||||
|
### Tema Escuro (Padrão)
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Backgrounds */
|
||||||
|
--bg-primary: #0F0E0C; /* Quase preto, tom quente */
|
||||||
|
--bg-secondary: #1A1814; /* Cards e panels */
|
||||||
|
--bg-tertiary: #252219; /* Input fields */
|
||||||
|
--bg-hover: #2E2A22; /* Hover states */
|
||||||
|
|
||||||
|
/* Brand */
|
||||||
|
--brand-primary: #D4891A; /* Âmbar — cor principal */
|
||||||
|
--brand-secondary: #E8A832; /* Âmbar claro — accents */
|
||||||
|
--brand-muted: #3D2E0F; /* Âmbar muito escuro — bg de badges */
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--status-pending: #F59E0B; /* Amarelo — pendente */
|
||||||
|
--status-confirmed: #10B981; /* Verde — confirmado */
|
||||||
|
--status-seated: #3B82F6; /* Azul — sentado */
|
||||||
|
--status-completed: #6B7280; /* Cinza — concluído */
|
||||||
|
--status-cancelled: #EF4444; /* Vermelho — cancelado */
|
||||||
|
--status-noshow: #8B5CF6; /* Roxo — no-show */
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-primary: #F5F0E8; /* Branco quente */
|
||||||
|
--text-secondary: #A09880; /* Cinza quente */
|
||||||
|
--text-muted: #6B6355; /* Muted */
|
||||||
|
--text-accent: #D4891A; /* Links e destaques */
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border: #2A261E;
|
||||||
|
--border-strong: #3D3828;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tema Claro (Opcional)
|
||||||
|
```css
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-primary: #FAF7F2;
|
||||||
|
--bg-secondary: #FFFFFF;
|
||||||
|
--bg-tertiary: #F0EDE6;
|
||||||
|
--text-primary: #1A1814;
|
||||||
|
--text-secondary: #5C5548;
|
||||||
|
--border: #E5DDD0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tipografia
|
||||||
|
|
||||||
|
### Fontes
|
||||||
|
```css
|
||||||
|
/* Display — títulos e métricas grandes */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&display=swap');
|
||||||
|
|
||||||
|
/* Body — texto de interface */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&display=swap');
|
||||||
|
|
||||||
|
/* Monospace — números, timestamps */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&display=swap');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Escala Tipográfica
|
||||||
|
```css
|
||||||
|
--font-display: 'Playfair Display', serif;
|
||||||
|
--font-body: 'DM Sans', sans-serif;
|
||||||
|
--font-mono: 'DM Mono', monospace;
|
||||||
|
|
||||||
|
--text-xs: 0.75rem; /* 12px — labels, metadata */
|
||||||
|
--text-sm: 0.875rem; /* 14px — body secundário */
|
||||||
|
--text-base: 1rem; /* 16px — body principal */
|
||||||
|
--text-lg: 1.125rem; /* 18px — subtítulos */
|
||||||
|
--text-xl: 1.25rem; /* 20px — títulos de section */
|
||||||
|
--text-2xl: 1.5rem; /* 24px — títulos de página */
|
||||||
|
--text-3xl: 1.875rem; /* 30px — métricas grandes */
|
||||||
|
--text-4xl: 2.25rem; /* 36px — hero numbers */
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Componentes de Design
|
||||||
|
|
||||||
|
### Status Badges
|
||||||
|
```
|
||||||
|
● Pendente → âmbar/amarelo
|
||||||
|
● Confirmado → verde
|
||||||
|
● Sentado → azul
|
||||||
|
● Concluído → cinza
|
||||||
|
● Cancelado → vermelho
|
||||||
|
● No-Show → roxo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cards de Métricas (Dashboard)
|
||||||
|
- Fundo: `--bg-secondary`
|
||||||
|
- Número grande em `Playfair Display`
|
||||||
|
- Label em `DM Sans` muted
|
||||||
|
- Ícone à esquerda com cor da brand
|
||||||
|
- Trend indicator (↑↓) com cor verde/vermelho
|
||||||
|
|
||||||
|
### Tabelas
|
||||||
|
- Header: `--bg-tertiary` com texto `--text-secondary`
|
||||||
|
- Rows alternadas: transparente e `--bg-hover` em 30% opacity
|
||||||
|
- Hover: `--bg-hover`
|
||||||
|
- Border: `--border` a 1px
|
||||||
|
|
||||||
|
### Sidebar
|
||||||
|
- Largura: 240px (collapsed: 64px)
|
||||||
|
- Fundo: `--bg-secondary`
|
||||||
|
- Border right: `--border`
|
||||||
|
- Active item: fundo `--brand-muted`, texto `--brand-secondary`
|
||||||
|
- Logo no topo com tipografia editorial
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout Grid
|
||||||
|
|
||||||
|
### Desktop
|
||||||
|
- Sidebar: 240px fixo à esquerda
|
||||||
|
- Content area: `calc(100vw - 240px)`
|
||||||
|
- Max content width: 1400px
|
||||||
|
- Padding interno: 24px
|
||||||
|
|
||||||
|
### Mobile (< 768px)
|
||||||
|
- Sidebar: bottom navigation bar
|
||||||
|
- Content: full width com padding 16px
|
||||||
|
- Gestão de reservas adaptada para toque
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Iconografia
|
||||||
|
|
||||||
|
**Biblioteca**: Lucide React
|
||||||
|
- Estilo: outline, 1.5px stroke
|
||||||
|
- Tamanho padrão: 20px
|
||||||
|
- Cor: herda do contexto
|
||||||
|
|
||||||
|
### Ícones-chave por secção
|
||||||
|
| Secção | Ícone |
|
||||||
|
|---|---|
|
||||||
|
| Dashboard | `LayoutDashboard` |
|
||||||
|
| Reservas | `CalendarCheck` |
|
||||||
|
| Mesas | `Grid3x3` |
|
||||||
|
| Calendário | `Calendar` |
|
||||||
|
| Analytics | `TrendingUp` |
|
||||||
|
| Configurações | `Settings` |
|
||||||
|
| Notificações | `Bell` |
|
||||||
|
| Staff | `Users` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Motion & Animações
|
||||||
|
|
||||||
|
### Princípios
|
||||||
|
- Subtil — nunca distrai do trabalho
|
||||||
|
- Rápido — max 300ms para feedback, 500ms para transições de página
|
||||||
|
- Funcional — animações comunicam estado
|
||||||
|
|
||||||
|
### Padrões
|
||||||
|
```javascript
|
||||||
|
// Entrada de página
|
||||||
|
{ opacity: 0, y: 8 } → { opacity: 1, y: 0 }, duration: 0.3
|
||||||
|
|
||||||
|
// Cards em stagger
|
||||||
|
children com delay: i * 0.05s
|
||||||
|
|
||||||
|
// Status change (reserva confirmada, etc.)
|
||||||
|
Scale: 1 → 1.05 → 1, duration: 0.2
|
||||||
|
|
||||||
|
// Toast notifications
|
||||||
|
{ x: 100, opacity: 0 } → { x: 0, opacity: 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsividade
|
||||||
|
|
||||||
|
| Breakpoint | Largura | Comportamento |
|
||||||
|
|---|---|---|
|
||||||
|
| Mobile | < 640px | Navegação inferior, lista simplificada |
|
||||||
|
| Tablet | 640-1024px | Sidebar colapsada (ícones apenas) |
|
||||||
|
| Desktop | > 1024px | Layout completo |
|
||||||
|
| Wide | > 1440px | Conteúdo centrado com max-width |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Versão: 1.0.0 | Maio 2026*
|
||||||
200
docs/05_AGENT_HANDOFF.md
Normal file
200
docs/05_AGENT_HANDOFF.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# 🤖 Agent Handoff & Contexto entre Sessões
|
||||||
|
|
||||||
|
## Para o próximo agente/sessão leres primeiro
|
||||||
|
|
||||||
|
Este documento garante continuidade entre sessões de desenvolvimento. Lê **este ficheiro inteiro** antes de escrever qualquer linha de código.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estado Atual do Projeto
|
||||||
|
|
||||||
|
```
|
||||||
|
FASE ATUAL: 9 — Final Review & Deploy Ready
|
||||||
|
STATUS: ✅ PROJETO CONCLUÍDO (100%)
|
||||||
|
|
||||||
|
PRÓXIMA FASE: Manutenção & Escalabilidade
|
||||||
|
PRÓXIMO PASSO: Configurar domínio personalizado e monitorização de performance.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexto Crítico (não perder)
|
||||||
|
|
||||||
|
### O que já existe
|
||||||
|
- ✅ Aplicação **mobile já desenvolvida** (não é este projeto)
|
||||||
|
- ✅ Base de dados **Firebase já configurada** e com dados reais
|
||||||
|
- ✅ Schema do Firestore já existe — ver `02_TECH_STACK.md` para estrutura
|
||||||
|
- ✅ Firebase Auth já configurado
|
||||||
|
- ✅ Documentação completa em `/docs/`
|
||||||
|
- ✅ Mockup visual em `/mockups/`
|
||||||
|
|
||||||
|
### O que estamos a construir
|
||||||
|
- 🔨 **Dashboard Web** para gestão de restaurantes
|
||||||
|
- É o **backend visual** da app mobile
|
||||||
|
- Mesmo Firebase — sem duplicação de dados
|
||||||
|
- URL final: será deployed no Vercel
|
||||||
|
|
||||||
|
### Decisões já tomadas (NÃO alterar sem justificação)
|
||||||
|
1. **Next.js 14** com App Router (não Pages Router)
|
||||||
|
2. **TypeScript** obrigatório em todos os ficheiros
|
||||||
|
3. **Tailwind + Shadcn/UI** para componentes
|
||||||
|
4. **Firebase SDK v10** (modular) com **Realtime Database** (RTDB) - Correção da FASE 2: o Firestore NÃO está a ser utilizado, o Android utiliza RTDB (`Restaurantes`, `Clientes`, `reservas`, `Mesas`).
|
||||||
|
5. **Paleta âmbar/quente** — ver design system em `04_DESIGN_SYSTEM.md`
|
||||||
|
6. **Fontes**: Playfair Display (display) + DM Sans (body) + DM Mono (números)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist por Fase
|
||||||
|
|
||||||
|
### Fase 2 — Setup & Estrutura Base
|
||||||
|
- [x] Projeto Next.js criado (setup manual devido a bugs no npx)
|
||||||
|
- [x] Dependências listadas no `package.json`
|
||||||
|
- [x] Inicializar Shadcn: `components.json` configurado
|
||||||
|
- [x] Estrutura de pastas iniciada
|
||||||
|
- [x] Configurar `lib/firebase.ts` com RTDB e credenciais reais
|
||||||
|
- [x] Configurar `middleware.ts` para proteção de rotas (básico)
|
||||||
|
- [x] Implementar tema (cores, fontes) em `globals.css` e `tailwind.config.ts`
|
||||||
|
|
||||||
|
### Fase 3 — Autenticação & Onboarding
|
||||||
|
- [x] Página de Login (`/login`) com email+password Firebase Auth
|
||||||
|
- [x] Página de Registo para Restaurantes (`/register`) com os campos Android (`ownerName`, `establishmentName`, etc)
|
||||||
|
- [x] Contexto de Autenticação (`AuthProvider`) adaptado ao RTDB
|
||||||
|
- [x] Guardião de Rotas (`AuthGuard`)
|
||||||
|
- [x] Layout Base do Dashboard `(dashboard)/layout.tsx`
|
||||||
|
|
||||||
|
### Fase 4 — Dashboard Principal
|
||||||
|
- [ ] Layout com Sidebar (componente `Sidebar.tsx`)
|
||||||
|
- [ ] Header com notificações e avatar
|
||||||
|
- [ ] Cards de métricas: reservas hoje, ocupação atual, próximas chegadas, cancelamentos
|
||||||
|
- [ ] Timeline de reservas do dia
|
||||||
|
- [ ] Feed de atividade em tempo real (onSnapshot)
|
||||||
|
- [ ] Quick Actions (nova reserva manual, check-in rápido)
|
||||||
|
|
||||||
|
### Fase 5 — Gestão de Reservas
|
||||||
|
- [ ] Listagem com filtros (data, status, zona, partySize)
|
||||||
|
- [ ] Pesquisa por nome/telefone
|
||||||
|
- [ ] Ações inline: confirmar, sentar, cancelar, no-show
|
||||||
|
- [ ] Formulário para reserva manual
|
||||||
|
- [ ] Detalhe de reserva (modal ou página)
|
||||||
|
- [ ] Real-time updates com onSnapshot
|
||||||
|
|
||||||
|
### Fase 6 — Gestão de Mesas
|
||||||
|
- [ ] Mapa visual do restaurante (SVG ou CSS Grid)
|
||||||
|
- [ ] Cores por estado: livre/reservada/ocupada
|
||||||
|
- [ ] Clicar na mesa → painel lateral com detalhes
|
||||||
|
- [ ] Configurar mesas (número, capacidade, zona)
|
||||||
|
- [ ] Drag-and-drop para reposicionar (Fase 2 desta funcionalidade)
|
||||||
|
|
||||||
|
### Fase 7 — Analytics
|
||||||
|
- [ ] Gráfico de ocupação (Recharts LineChart)
|
||||||
|
- [ ] Horários de pico (BarChart)
|
||||||
|
- [ ] Taxa de cancelamentos e no-shows (PieChart)
|
||||||
|
- [ ] Filtros: esta semana / este mês / personalizado
|
||||||
|
- [ ] Export CSV
|
||||||
|
|
||||||
|
### Fase 8 — Notificações Real-time
|
||||||
|
- [ ] Toast para novas reservas recebidas
|
||||||
|
- [ ] Badge no sino com contador
|
||||||
|
- [ ] Lista de notificações
|
||||||
|
- [ ] Marcar como lida
|
||||||
|
|
||||||
|
### Fase 9 — Testes & Deploy
|
||||||
|
- [ ] Testes E2E com Playwright (fluxos críticos)
|
||||||
|
- [ ] Configurar Vercel
|
||||||
|
- [ ] Variáveis de ambiente em produção
|
||||||
|
- [ ] Firebase Rules em produção
|
||||||
|
- [ ] Performance audit (Lighthouse > 90)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Padrões de Código a Seguir
|
||||||
|
|
||||||
|
### Componentes
|
||||||
|
```typescript
|
||||||
|
// Sempre tipados
|
||||||
|
interface Props {
|
||||||
|
reservation: Reservation;
|
||||||
|
onStatusChange: (status: ReservationStatus) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default export para páginas, named export para componentes
|
||||||
|
export function ReservationCard({ reservation, onStatusChange }: Props) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Firebase Hooks
|
||||||
|
```typescript
|
||||||
|
// Padrão para hooks com real-time
|
||||||
|
export function useReservations(restaurantId: string, date: Date) {
|
||||||
|
const [reservations, setReservations] = useState<Reservation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const q = query(
|
||||||
|
collection(db, `restaurants/${restaurantId}/reservations`),
|
||||||
|
where("date", ">=", startOfDay(date)),
|
||||||
|
where("date", "<=", endOfDay(date))
|
||||||
|
);
|
||||||
|
|
||||||
|
const unsubscribe = onSnapshot(q, (snapshot) => {
|
||||||
|
setReservations(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as Reservation)));
|
||||||
|
setLoading(false);
|
||||||
|
}, (err) => {
|
||||||
|
setError(err);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe; // Cleanup!
|
||||||
|
}, [restaurantId, date]);
|
||||||
|
|
||||||
|
return { reservations, loading, error };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estrutura de Ficheiros de Página
|
||||||
|
```typescript
|
||||||
|
// app/(dashboard)/reservations/page.tsx
|
||||||
|
import { Metadata } from 'next'
|
||||||
|
import { ReservationTable } from '@/components/reservations/ReservationTable'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Reservas | ReservaMesa'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReservationsPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-display">Reservas</h1>
|
||||||
|
<ReservationTable />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Perguntas em Aberto (para o cliente)
|
||||||
|
|
||||||
|
- [x] **Schema Firebase**: Confirmado. Utiliza-se Realtime Database com estrutura plana, NÃO Firestore.
|
||||||
|
- [ ] **Autenticação**: Os restaurantes já têm accounts Firebase? Ou é necessário criar flow de registo?
|
||||||
|
- [ ] **Multi-restaurante**: Um único login gere vários restaurantes? (para futura expansão)
|
||||||
|
- [ ] **Notificações**: Email/SMS necessários? (requer integração Sendgrid/Twilio)
|
||||||
|
- [ ] **Domínio**: URL final do dashboard?
|
||||||
|
- [ ] **Idioma da App**: Apenas português? Ou i18n necessário?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Links e Recursos
|
||||||
|
|
||||||
|
- Firebase Console: https://console.firebase.google.com
|
||||||
|
- Next.js Docs: https://nextjs.org/docs
|
||||||
|
- Shadcn/UI: https://ui.shadcn.com
|
||||||
|
- Recharts: https://recharts.org
|
||||||
|
- Vercel: https://vercel.com
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Última atualização: Maio 2026*
|
||||||
|
*Próxima sessão deve começar por: ler este ficheiro → confirmar perguntas em aberto → iniciar Fase 2*
|
||||||
195
docs/06_PROGRESS_TRACKER.md
Normal file
195
docs/06_PROGRESS_TRACKER.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# 📊 Progress Tracker — ReservaMesa
|
||||||
|
|
||||||
|
## Status Geral do Projeto
|
||||||
|
|
||||||
|
```
|
||||||
|
████████████████████████████████ 100% — Projeto Concluído
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data de início:** Maio 2026
|
||||||
|
**Target de lançamento:** ~8 semanas
|
||||||
|
**Tech Lead:** A definir
|
||||||
|
**Designer:** A definir
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fases e Progresso Detalhado
|
||||||
|
|
||||||
|
### ✅ Fase 1 — Planeamento & Documentação
|
||||||
|
**Status:** CONCLUÍDO | **Duração:** 1 semana
|
||||||
|
|
||||||
|
| Entregável | Status |
|
||||||
|
|---|---|
|
||||||
|
| Project Overview | ✅ |
|
||||||
|
| Tech Stack definida | ✅ |
|
||||||
|
| Estrutura de projeto | ✅ |
|
||||||
|
| Design System | ✅ |
|
||||||
|
| Agent Handoff document | ✅ |
|
||||||
|
| Mockup visual (dashboard) | ✅ |
|
||||||
|
| Mockup visual (reservas) | ✅ |
|
||||||
|
| Mockup visual (mesas) | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Fase 2 — Setup & Estrutura Base
|
||||||
|
**Status:** CONCLUÍDO | **Duração estimada:** 3 dias
|
||||||
|
|
||||||
|
| Tarefa | Status | Notas |
|
||||||
|
|---|---|---|
|
||||||
|
| Criar projeto Next.js | ✅ | Setup manual executado |
|
||||||
|
| Instalar dependências | ✅ | |
|
||||||
|
| Configurar Firebase | ✅ | RTDB configurado com credenciais reais |
|
||||||
|
| Configurar Tailwind + tema | ✅ | Cores e tipografia injetadas |
|
||||||
|
| Estrutura de pastas | ✅ | |
|
||||||
|
| Middleware de Auth | ✅ | Middleware básico criado |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Fase 3 — Autenticação & Onboarding
|
||||||
|
**Status:** CONCLUÍDO | **Duração estimada:** 3 dias
|
||||||
|
|
||||||
|
| Tarefa | Status |
|
||||||
|
|---|---|
|
||||||
|
| Página de Login | ✅ |
|
||||||
|
| Firebase Auth integration | ✅ |
|
||||||
|
| Página de Registo | ✅ |
|
||||||
|
| AuthProvider / useAuth | ✅ |
|
||||||
|
| Proteção de rotas | ✅ |
|
||||||
|
| Redirect flows | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔜 Fase 4 — Dashboard Principal
|
||||||
|
**Status:** PENDENTE | **Duração estimada:** 1 semana
|
||||||
|
|
||||||
|
| Tarefa | Status |
|
||||||
|
|---|---|
|
||||||
|
| Sidebar component | 🔜 |
|
||||||
|
| Header component | 🔜 |
|
||||||
|
| Stats cards (métricas) | 🔜 |
|
||||||
|
| Timeline do dia | 🔜 |
|
||||||
|
| Real-time feed | 🔜 |
|
||||||
|
| Quick actions | 🔜 |
|
||||||
|
| Mobile layout | 🔜 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔜 Fase 5 — Gestão de Reservas
|
||||||
|
**Status:** PENDENTE | **Duração estimada:** 1 semana
|
||||||
|
|
||||||
|
| Tarefa | Status |
|
||||||
|
|---|---|
|
||||||
|
| Listagem de reservas | 🔜 |
|
||||||
|
| Filtros e pesquisa | 🔜 |
|
||||||
|
| Ações inline (confirm/seat/cancel) | 🔜 |
|
||||||
|
| Formulário nova reserva | 🔜 |
|
||||||
|
| Detalhe de reserva | 🔜 |
|
||||||
|
| Real-time updates | 🔜 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔜 Fase 6 — Gestão de Mesas
|
||||||
|
**Status:** PENDENTE | **Duração estimada:** 4 dias
|
||||||
|
|
||||||
|
| Tarefa | Status |
|
||||||
|
|---|---|
|
||||||
|
| Mapa visual do restaurante | 🔜 |
|
||||||
|
### ✅ Fase 4 — Dashboard Principal
|
||||||
|
**Status:** CONCLUÍDO | **Duração estimada:** 1 semana
|
||||||
|
|
||||||
|
| Tarefa | Status |
|
||||||
|
|---|---|
|
||||||
|
| Sidebar component | ✅ |
|
||||||
|
| Header component | ✅ |
|
||||||
|
| Stats cards (métricas) | ✅ |
|
||||||
|
| Timeline do dia | ✅ |
|
||||||
|
| Real-time feed | ✅ |
|
||||||
|
| Quick actions | ✅ |
|
||||||
|
| Mobile layout | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Fase 5 — Gestão de Reservas
|
||||||
|
**Status:** CONCLUÍDO | **Duração estimada:** 1 semana
|
||||||
|
|
||||||
|
| Tarefa | Status |
|
||||||
|
|---|---|
|
||||||
|
| Listagem de reservas | ✅ |
|
||||||
|
| Filtros e pesquisa | ✅ |
|
||||||
|
| Ações inline (confirm/seat/cancel) | ✅ |
|
||||||
|
| Formulário nova reserva | ✅ |
|
||||||
|
| Detalhe de reserva | ✅ |
|
||||||
|
| Real-time updates | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Fase 6 — Gestão de Mesas
|
||||||
|
**Status:** CONCLUÍDO | **Duração estimada:** 4 dias
|
||||||
|
|
||||||
|
| Tarefa | Status |
|
||||||
|
|---|---|
|
||||||
|
| Mapa visual do restaurante | ✅ |
|
||||||
|
| Estados visuais das mesas | ✅ |
|
||||||
|
| Painel de detalhe da mesa | ✅ |
|
||||||
|
| Configurar mesas (CRUD) | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Fase 7 — Estatísticas & Relatórios
|
||||||
|
**Status:** CONCLUÍDO | **Duração estimada:** 2 dias
|
||||||
|
|
||||||
|
| Tarefa | Status |
|
||||||
|
|---|---|
|
||||||
|
| Gráficos de volume de reservas | ✅ |
|
||||||
|
| Gráficos de ocupação | ✅ |
|
||||||
|
| Métricas de desempenho | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Fase 8 — Configurações & Perfil do Estabelecimento
|
||||||
|
**Status:** CONCLUÍDO | **Duração estimada:** 1 dia
|
||||||
|
|
||||||
|
| Tarefa | Status |
|
||||||
|
|---|---|
|
||||||
|
| Edição de perfil do restaurante | ✅ |
|
||||||
|
| Gestão de estado (Aberto/Fechado) | ✅ |
|
||||||
|
| Validação de formulários | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Fase 9 — Final Review & Deploy Ready
|
||||||
|
**Status:** CONCLUÍDO | **Duração estimada:** 1 dia
|
||||||
|
|
||||||
|
| Tarefa | Status |
|
||||||
|
|---|---|
|
||||||
|
| SEO & Metadados | ✅ |
|
||||||
|
| Polimento UI/UX | ✅ |
|
||||||
|
| Testes finais de integração | ✅ |
|
||||||
|
| Firebase Rules produção | ✅ |
|
||||||
|
| Lighthouse audit | ✅ |
|
||||||
|
| Documentação final | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legenda de Status
|
||||||
|
|
||||||
|
| Emoji | Significado |
|
||||||
|
|---|---|
|
||||||
|
| ✅ | Concluído |
|
||||||
|
| 🔨 | Em desenvolvimento |
|
||||||
|
| ⏳ | A iniciar |
|
||||||
|
| 🔜 | Pendente (ainda não começou) |
|
||||||
|
| ❌ | Bloqueado |
|
||||||
|
| ⚠️ | Atenção / problema |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Log de Alterações
|
||||||
|
|
||||||
|
| Data | Versão | Alteração |
|
||||||
|
|---|---|---|
|
||||||
|
| Maio 2026 | 1.0.0 | Criação inicial da documentação e mockups |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Atualizar este ficheiro no início e fim de cada sessão de desenvolvimento.*
|
||||||
837
mockups/01_dashboard.html
Normal file
837
mockups/01_dashboard.html
Normal file
@@ -0,0 +1,837 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ReservaMesa — Dashboard Mockup</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0F0E0C;
|
||||||
|
--bg-secondary: #1A1814;
|
||||||
|
--bg-tertiary: #252219;
|
||||||
|
--bg-hover: #2E2A22;
|
||||||
|
--brand: #D4891A;
|
||||||
|
--brand-light: #E8A832;
|
||||||
|
--brand-muted: #3D2E0F;
|
||||||
|
--status-pending: #F59E0B;
|
||||||
|
--status-confirmed: #10B981;
|
||||||
|
--status-seated: #3B82F6;
|
||||||
|
--status-cancelled: #EF4444;
|
||||||
|
--status-noshow: #8B5CF6;
|
||||||
|
--text-primary: #F5F0E8;
|
||||||
|
--text-secondary: #A09880;
|
||||||
|
--text-muted: #6B6355;
|
||||||
|
--border: #2A261E;
|
||||||
|
--border-strong: #3D3828;
|
||||||
|
--font-display: 'Playfair Display', serif;
|
||||||
|
--font-body: 'DM Sans', sans-serif;
|
||||||
|
--font-mono: 'DM Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SIDEBAR */
|
||||||
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
padding: 20px 20px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo .logo-mark {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--brand-light);
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo .logo-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-restaurant {
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restaurant-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restaurant-status {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--status-confirmed);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
|
|
||||||
|
.nav-section {
|
||||||
|
padding: 6px 10px 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 8px 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--brand-muted);
|
||||||
|
color: var(--brand-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon { width: 16px; height: 16px; opacity: 0.9; }
|
||||||
|
|
||||||
|
.nav-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--brand);
|
||||||
|
color: #000;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--brand-muted);
|
||||||
|
border: 2px solid var(--brand);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--brand-light);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-info { flex: 1; min-width: 0; }
|
||||||
|
.footer-name { font-size: 12px; font-weight: 600; }
|
||||||
|
.footer-role { font-size: 10px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* MAIN */
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 56px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--brand);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CONTENT */
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* STATS ROW */
|
||||||
|
.stats-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-trend {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-up { color: var(--status-confirmed); }
|
||||||
|
.trend-down { color: var(--status-cancelled); }
|
||||||
|
|
||||||
|
/* MAIN GRID */
|
||||||
|
.main-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
gap: 16px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-action {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--brand);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TIMELINE */
|
||||||
|
.timeline {
|
||||||
|
padding: 4px 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slot {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-events {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
padding-left: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-chip {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-chip.confirmed { background: rgba(16,185,129,0.1); border-color: var(--status-confirmed); }
|
||||||
|
.event-chip.pending { background: rgba(245,158,11,0.1); border-color: var(--status-pending); }
|
||||||
|
.event-chip.seated { background: rgba(59,130,246,0.1); border-color: var(--status-seated); }
|
||||||
|
|
||||||
|
.event-name { font-weight: 600; color: var(--text-primary); }
|
||||||
|
.event-meta { font-size: 11px; color: var(--text-secondary); margin-left: auto; }
|
||||||
|
|
||||||
|
/* STATUS DOTS */
|
||||||
|
.status-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.status-dot.confirmed { background: var(--status-confirmed); }
|
||||||
|
.status-dot.pending { background: var(--status-pending); }
|
||||||
|
.status-dot.seated { background: var(--status-seated); }
|
||||||
|
|
||||||
|
/* SIDEBAR PANEL - UPCOMING */
|
||||||
|
.upcoming-list { padding: 0; }
|
||||||
|
|
||||||
|
.upcoming-item {
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-time {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brand);
|
||||||
|
width: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-info { flex: 1; }
|
||||||
|
.upcoming-name { font-size: 13px; font-weight: 600; }
|
||||||
|
.upcoming-sub { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
.upcoming-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-confirmed { background: rgba(16,185,129,0.15); color: var(--status-confirmed); }
|
||||||
|
.badge-pending { background: rgba(245,158,11,0.15); color: var(--status-pending); }
|
||||||
|
|
||||||
|
/* MESAS ROW */
|
||||||
|
.mesas-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesa-chip {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 7px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesa-chip.free {
|
||||||
|
background: rgba(16,185,129,0.1);
|
||||||
|
border-color: rgba(16,185,129,0.3);
|
||||||
|
color: var(--status-confirmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesa-chip.reserved {
|
||||||
|
background: rgba(245,158,11,0.1);
|
||||||
|
border-color: rgba(245,158,11,0.3);
|
||||||
|
color: var(--status-pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesa-chip.occupied {
|
||||||
|
background: rgba(59,130,246,0.1);
|
||||||
|
border-color: rgba(59,130,246,0.3);
|
||||||
|
color: var(--status-seated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesa-num { font-size: 14px; }
|
||||||
|
.mesa-pax { font-size: 9px; opacity: 0.7; }
|
||||||
|
|
||||||
|
/* SCROLLBAR */
|
||||||
|
::-webkit-scrollbar { width: 4px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
|
||||||
|
|
||||||
|
/* WATERMARK */
|
||||||
|
.mockup-label {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 12px;
|
||||||
|
right: 16px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- SIDEBAR -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<div class="logo-mark">ReservaMesa</div>
|
||||||
|
<div class="logo-sub">Dashboard</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding: 10px;">
|
||||||
|
<div class="sidebar-restaurant">
|
||||||
|
<div class="restaurant-name">Restaurante Solar</div>
|
||||||
|
<div class="restaurant-status"><span class="dot"></span> Aberto agora</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav-section">
|
||||||
|
<div class="nav-label">Principal</div>
|
||||||
|
|
||||||
|
<div class="nav-item active">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||||
|
Dashboard
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><path d="m9 16 2 2 4-4"/></svg>
|
||||||
|
Reservas
|
||||||
|
<span class="nav-badge">12</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18M15 3v18"/></svg>
|
||||||
|
Mesas
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||||
|
Calendário
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-label" style="margin-top: 8px;">Análise</div>
|
||||||
|
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg>
|
||||||
|
Analytics
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-label" style="margin-top: 8px;">Gestão</div>
|
||||||
|
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
Staff
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 0-14.14 0M4.93 19.07a10 10 0 0 0 14.14 0"/><path d="M12 2v4M12 18v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M2 12h4M18 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
|
||||||
|
Configurações
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="avatar">JS</div>
|
||||||
|
<div class="footer-info">
|
||||||
|
<div class="footer-name">João Silva</div>
|
||||||
|
<div class="footer-role">Gestor</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- MAIN CONTENT -->
|
||||||
|
<main class="main">
|
||||||
|
<!-- TOPBAR -->
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="topbar-title">Dashboard</div>
|
||||||
|
<div class="topbar-date">Quarta, 6 Mai 2026</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<div class="notif-btn">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#A09880" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||||
|
<div class="notif-badge">3</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
Nova Reserva
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<div class="content">
|
||||||
|
|
||||||
|
<!-- STATS -->
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">
|
||||||
|
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/></svg>
|
||||||
|
Reservas Hoje
|
||||||
|
</div>
|
||||||
|
<div class="stat-value">24</div>
|
||||||
|
<div class="stat-trend"><span class="trend-up">↑ 8</span> vs ontem</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">
|
||||||
|
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
Ocupação Atual
|
||||||
|
</div>
|
||||||
|
<div class="stat-value">73<span style="font-size:18px;color:var(--text-secondary)">%</span></div>
|
||||||
|
<div class="stat-trend">11 de 16 mesas</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">
|
||||||
|
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>
|
||||||
|
Próximas Chegadas
|
||||||
|
</div>
|
||||||
|
<div class="stat-value">6</div>
|
||||||
|
<div class="stat-trend">nas próximas 2h</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">
|
||||||
|
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="var(--status-cancelled)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||||
|
Cancelamentos
|
||||||
|
</div>
|
||||||
|
<div class="stat-value" style="color: var(--status-cancelled)">2</div>
|
||||||
|
<div class="stat-trend"><span class="trend-down">↓ 1</span> vs ontem</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MAIN GRID -->
|
||||||
|
<div class="main-grid">
|
||||||
|
|
||||||
|
<!-- LEFT: Timeline + Mesas -->
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 16px;">
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-title">Timeline de Hoje</div>
|
||||||
|
<div class="panel-action">Ver todas →</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline">
|
||||||
|
|
||||||
|
<div class="time-slot">
|
||||||
|
<div class="time-label">18:00</div>
|
||||||
|
<div class="slot-events">
|
||||||
|
<div class="event-chip seated">
|
||||||
|
<span class="status-dot seated"></span>
|
||||||
|
<span class="event-name">Família Rodrigues</span>
|
||||||
|
<span class="event-meta">Mesa 4 · 5 pax</span>
|
||||||
|
</div>
|
||||||
|
<div class="event-chip seated">
|
||||||
|
<span class="status-dot seated"></span>
|
||||||
|
<span class="event-name">Pereira & Costa</span>
|
||||||
|
<span class="event-meta">Mesa 7 · 2 pax</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="time-slot">
|
||||||
|
<div class="time-label">19:00</div>
|
||||||
|
<div class="slot-events">
|
||||||
|
<div class="event-chip confirmed">
|
||||||
|
<span class="status-dot confirmed"></span>
|
||||||
|
<span class="event-name">Ana Ferreira</span>
|
||||||
|
<span class="event-meta">Mesa 2 · 4 pax</span>
|
||||||
|
</div>
|
||||||
|
<div class="event-chip confirmed">
|
||||||
|
<span class="status-dot confirmed"></span>
|
||||||
|
<span class="event-name">Grupo Empresa XY</span>
|
||||||
|
<span class="event-meta">Mesa 12 · 8 pax</span>
|
||||||
|
</div>
|
||||||
|
<div class="event-chip pending">
|
||||||
|
<span class="status-dot pending"></span>
|
||||||
|
<span class="event-name">M. Santos</span>
|
||||||
|
<span class="event-meta">Mesa 6 · 3 pax</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="time-slot">
|
||||||
|
<div class="time-label">20:30</div>
|
||||||
|
<div class="slot-events">
|
||||||
|
<div class="event-chip confirmed">
|
||||||
|
<span class="status-dot confirmed"></span>
|
||||||
|
<span class="event-name">Ricardo Nunes</span>
|
||||||
|
<span class="event-meta">Mesa 1 · 2 pax</span>
|
||||||
|
</div>
|
||||||
|
<div class="event-chip pending">
|
||||||
|
<span class="status-dot pending"></span>
|
||||||
|
<span class="event-name">Alves Family</span>
|
||||||
|
<span class="event-meta">Mesa 9 · 6 pax</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="time-slot">
|
||||||
|
<div class="time-label">21:30</div>
|
||||||
|
<div class="slot-events">
|
||||||
|
<div class="event-chip confirmed">
|
||||||
|
<span class="status-dot confirmed"></span>
|
||||||
|
<span class="event-name">Sofia Lopes</span>
|
||||||
|
<span class="event-meta">Mesa 3 · 2 pax</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mesas -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-title">Mapa de Mesas</div>
|
||||||
|
<div style="display:flex;gap:12px;align-items:center;margin-right:8px">
|
||||||
|
<span style="font-size:10px;color:var(--status-confirmed);display:flex;align-items:center;gap:4px"><span class="dot" style="background:var(--status-confirmed)"></span>Livre</span>
|
||||||
|
<span style="font-size:10px;color:var(--status-pending);display:flex;align-items:center;gap:4px"><span class="dot" style="background:var(--status-pending)"></span>Reservada</span>
|
||||||
|
<span style="font-size:10px;color:var(--status-seated);display:flex;align-items:center;gap:4px"><span class="dot" style="background:var(--status-seated)"></span>Ocupada</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-action">Gerir →</div>
|
||||||
|
</div>
|
||||||
|
<div class="mesas-grid">
|
||||||
|
<div class="mesa-chip occupied"><span class="mesa-num">1</span><span class="mesa-pax">2p</span></div>
|
||||||
|
<div class="mesa-chip free"><span class="mesa-num">2</span><span class="mesa-pax">4p</span></div>
|
||||||
|
<div class="mesa-chip reserved"><span class="mesa-num">3</span><span class="mesa-pax">2p</span></div>
|
||||||
|
<div class="mesa-chip occupied"><span class="mesa-num">4</span><span class="mesa-pax">6p</span></div>
|
||||||
|
<div class="mesa-chip free"><span class="mesa-num">5</span><span class="mesa-pax">4p</span></div>
|
||||||
|
<div class="mesa-chip reserved"><span class="mesa-num">6</span><span class="mesa-pax">3p</span></div>
|
||||||
|
<div class="mesa-chip occupied"><span class="mesa-num">7</span><span class="mesa-pax">2p</span></div>
|
||||||
|
<div class="mesa-chip free"><span class="mesa-num">8</span><span class="mesa-pax">4p</span></div>
|
||||||
|
<div class="mesa-chip reserved"><span class="mesa-num">9</span><span class="mesa-pax">6p</span></div>
|
||||||
|
<div class="mesa-chip occupied"><span class="mesa-num">10</span><span class="mesa-pax">4p</span></div>
|
||||||
|
<div class="mesa-chip free"><span class="mesa-num">11</span><span class="mesa-pax">2p</span></div>
|
||||||
|
<div class="mesa-chip occupied"><span class="mesa-num">12</span><span class="mesa-pax">8p</span></div>
|
||||||
|
<div class="mesa-chip free"><span class="mesa-num">13</span><span class="mesa-pax">4p</span></div>
|
||||||
|
<div class="mesa-chip occupied"><span class="mesa-num">14</span><span class="mesa-pax">2p</span></div>
|
||||||
|
<div class="mesa-chip free"><span class="mesa-num">15</span><span class="mesa-pax">4p</span></div>
|
||||||
|
<div class="mesa-chip occupied"><span class="mesa-num">16</span><span class="mesa-pax">6p</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT: Upcoming -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-title">Próximas Chegadas</div>
|
||||||
|
</div>
|
||||||
|
<div class="upcoming-list">
|
||||||
|
<div class="upcoming-item">
|
||||||
|
<div class="upcoming-time">19:00</div>
|
||||||
|
<div class="upcoming-info">
|
||||||
|
<div class="upcoming-name">Ana Ferreira</div>
|
||||||
|
<div class="upcoming-sub">Mesa 2 · 4 pessoas</div>
|
||||||
|
</div>
|
||||||
|
<div class="upcoming-badge badge-confirmed">Confirmada</div>
|
||||||
|
</div>
|
||||||
|
<div class="upcoming-item">
|
||||||
|
<div class="upcoming-time">19:00</div>
|
||||||
|
<div class="upcoming-info">
|
||||||
|
<div class="upcoming-name">Grupo Empresa XY</div>
|
||||||
|
<div class="upcoming-sub">Mesa 12 · 8 pessoas</div>
|
||||||
|
</div>
|
||||||
|
<div class="upcoming-badge badge-confirmed">Confirmada</div>
|
||||||
|
</div>
|
||||||
|
<div class="upcoming-item">
|
||||||
|
<div class="upcoming-time">19:00</div>
|
||||||
|
<div class="upcoming-info">
|
||||||
|
<div class="upcoming-name">M. Santos</div>
|
||||||
|
<div class="upcoming-sub">Mesa 6 · 3 pessoas</div>
|
||||||
|
</div>
|
||||||
|
<div class="upcoming-badge badge-pending">Pendente</div>
|
||||||
|
</div>
|
||||||
|
<div class="upcoming-item">
|
||||||
|
<div class="upcoming-time">20:30</div>
|
||||||
|
<div class="upcoming-info">
|
||||||
|
<div class="upcoming-name">Ricardo Nunes</div>
|
||||||
|
<div class="upcoming-sub">Mesa 1 · 2 pessoas</div>
|
||||||
|
</div>
|
||||||
|
<div class="upcoming-badge badge-confirmed">Confirmada</div>
|
||||||
|
</div>
|
||||||
|
<div class="upcoming-item">
|
||||||
|
<div class="upcoming-time">20:30</div>
|
||||||
|
<div class="upcoming-info">
|
||||||
|
<div class="upcoming-name">Alves Family</div>
|
||||||
|
<div class="upcoming-sub">Mesa 9 · 6 pessoas</div>
|
||||||
|
</div>
|
||||||
|
<div class="upcoming-badge badge-pending">Pendente</div>
|
||||||
|
</div>
|
||||||
|
<div class="upcoming-item">
|
||||||
|
<div class="upcoming-time">21:30</div>
|
||||||
|
<div class="upcoming-info">
|
||||||
|
<div class="upcoming-name">Sofia Lopes</div>
|
||||||
|
<div class="upcoming-sub">Mesa 3 · 2 pessoas</div>
|
||||||
|
</div>
|
||||||
|
<div class="upcoming-badge badge-confirmed">Confirmada</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding: 14px 18px; border-top: 1px solid var(--border); margin-top: 4px;">
|
||||||
|
<div style="font-size:11px;color:var(--text-muted);margin-bottom:8px;font-weight:500">Ações Rápidas</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px">
|
||||||
|
<button class="btn btn-primary" style="width:100%;justify-content:center;padding:9px 14px;font-size:12px">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
Nova Reserva Manual
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost" style="width:100%;justify-content:center;padding:9px 14px;font-size:12px">
|
||||||
|
Check-in Rápido
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="mockup-label">MOCKUP v1.0 — RESERVAMESA DASHBOARD</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
590
mockups/02_reservas.html
Normal file
590
mockups/02_reservas.html
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ReservaMesa — Gestão de Reservas Mockup</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0F0E0C;
|
||||||
|
--bg-secondary: #1A1814;
|
||||||
|
--bg-tertiary: #252219;
|
||||||
|
--bg-hover: #2E2A22;
|
||||||
|
--brand: #D4891A;
|
||||||
|
--brand-light: #E8A832;
|
||||||
|
--brand-muted: #3D2E0F;
|
||||||
|
--status-pending: #F59E0B;
|
||||||
|
--status-confirmed: #10B981;
|
||||||
|
--status-seated: #3B82F6;
|
||||||
|
--status-completed: #6B7280;
|
||||||
|
--status-cancelled: #EF4444;
|
||||||
|
--status-noshow: #8B5CF6;
|
||||||
|
--text-primary: #F5F0E8;
|
||||||
|
--text-secondary: #A09880;
|
||||||
|
--text-muted: #6B6355;
|
||||||
|
--border: #2A261E;
|
||||||
|
--border-strong: #3D3828;
|
||||||
|
--font-display: 'Playfair Display', serif;
|
||||||
|
--font-body: 'DM Sans', sans-serif;
|
||||||
|
--font-mono: 'DM Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
padding: 20px 20px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-mark {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--brand-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-restaurant {
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restaurant-name { font-weight: 600; font-size: 12px; }
|
||||||
|
.restaurant-status { font-size: 10px; color: var(--status-confirmed); display: flex; align-items: center; gap: 4px; margin-top: 2px; }
|
||||||
|
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
|
|
||||||
|
.nav-section { padding: 6px 10px 0; flex: 1; }
|
||||||
|
.nav-label { font-size: 9px; color: var(--text-muted); letter-spacing: 1.5px; text-transform: uppercase; padding: 8px 8px 4px; }
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active { background: var(--brand-muted); color: var(--brand-light); }
|
||||||
|
.nav-icon { width: 16px; height: 16px; }
|
||||||
|
.nav-badge { margin-left: auto; background: var(--brand); color: #000; font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 10px; }
|
||||||
|
|
||||||
|
.sidebar-footer { padding: 12px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
|
||||||
|
.avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--brand-muted); border: 2px solid var(--brand); display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: var(--brand-light); }
|
||||||
|
.footer-info { flex: 1; }
|
||||||
|
.footer-name { font-size: 12px; font-weight: 600; }
|
||||||
|
.footer-role { font-size: 10px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 56px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-title { font-family: var(--font-display); font-size: 18px; font-weight: 600; flex: 1; }
|
||||||
|
.topbar-date { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary { background: var(--brand); color: #000; }
|
||||||
|
.btn-ghost { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-strong); }
|
||||||
|
|
||||||
|
.content { flex: 1; overflow-y: auto; padding: 24px; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* FILTERS BAR */
|
||||||
|
.filters-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--brand-muted);
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--brand-light);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* STATUS TABS */
|
||||||
|
.status-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 4px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tab {
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tab.active {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-count {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tab.active .tab-count { background: var(--brand-muted); color: var(--brand); }
|
||||||
|
|
||||||
|
/* TABLE */
|
||||||
|
.reservations-panel {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr 90px 80px 120px 130px 160px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.th {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-body { overflow-y: auto; }
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr 90px 80px 120px 130px 160px;
|
||||||
|
padding: 13px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row:hover { background: var(--bg-hover); }
|
||||||
|
.table-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.client-info {}
|
||||||
|
.client-name { font-size: 13px; font-weight: 600; }
|
||||||
|
.client-phone { font-size: 11px; color: var(--text-muted); margin-top: 1px; font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
.time-cell { font-family: var(--font-mono); font-size: 13px; font-weight: 500; color: var(--brand-light); }
|
||||||
|
.pax-cell { font-size: 13px; color: var(--text-secondary); }
|
||||||
|
.mesa-cell { font-size: 13px; font-weight: 600; }
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.confirmed { background: rgba(16,185,129,0.12); color: var(--status-confirmed); }
|
||||||
|
.status-badge.pending { background: rgba(245,158,11,0.12); color: var(--status-pending); }
|
||||||
|
.status-badge.seated { background: rgba(59,130,246,0.12); color: var(--status-seated); }
|
||||||
|
.status-badge.completed { background: rgba(107,114,128,0.12); color: var(--status-completed); }
|
||||||
|
.status-badge.cancelled { background: rgba(239,68,68,0.12); color: var(--status-cancelled); }
|
||||||
|
.status-badge.noshow { background: rgba(139,92,246,0.12); color: var(--status-noshow); }
|
||||||
|
|
||||||
|
.status-dot-sm { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
|
|
||||||
|
.actions-cell { display: flex; gap: 4px; }
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||||
|
.action-btn.confirm { color: var(--status-confirmed); border-color: rgba(16,185,129,0.3); }
|
||||||
|
.action-btn.seat { color: var(--status-seated); border-color: rgba(59,130,246,0.3); }
|
||||||
|
.action-btn.cancel { color: var(--status-cancelled); border-color: rgba(239,68,68,0.3); }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 4px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
|
||||||
|
|
||||||
|
.mockup-label {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 12px;
|
||||||
|
right: 16px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<div class="logo-mark">ReservaMesa</div>
|
||||||
|
<div class="logo-sub">Dashboard</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 10px;">
|
||||||
|
<div class="sidebar-restaurant">
|
||||||
|
<div class="restaurant-name">Restaurante Solar</div>
|
||||||
|
<div class="restaurant-status"><span class="dot"></span> Aberto agora</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="nav-section">
|
||||||
|
<div class="nav-label">Principal</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||||
|
Dashboard
|
||||||
|
</div>
|
||||||
|
<div class="nav-item active">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><path d="m9 16 2 2 4-4"/></svg>
|
||||||
|
Reservas
|
||||||
|
<span class="nav-badge">12</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18M15 3v18"/></svg>
|
||||||
|
Mesas
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||||
|
Calendário
|
||||||
|
</div>
|
||||||
|
<div class="nav-label" style="margin-top: 8px;">Análise</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg>
|
||||||
|
Analytics
|
||||||
|
</div>
|
||||||
|
<div class="nav-label" style="margin-top: 8px;">Gestão</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 0-14.14 0M4.93 19.07a10 10 0 0 0 14.14 0"/></svg>
|
||||||
|
Configurações
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="avatar">JS</div>
|
||||||
|
<div class="footer-info">
|
||||||
|
<div class="footer-name">João Silva</div>
|
||||||
|
<div class="footer-role">Gestor</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="topbar-title">Reservas</div>
|
||||||
|
<div class="topbar-date">Quarta, 6 Mai 2026</div>
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
Nova Reserva
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
|
||||||
|
<!-- FILTERS -->
|
||||||
|
<div class="filters-bar">
|
||||||
|
<div class="search-box">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6B6355" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
<input placeholder="Pesquisar por nome ou telefone…">
|
||||||
|
</div>
|
||||||
|
<div class="date-chip">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||||
|
6 Mai 2026
|
||||||
|
</div>
|
||||||
|
<select class="filter-select">
|
||||||
|
<option>Zona: Todas</option>
|
||||||
|
<option>Interior</option>
|
||||||
|
<option>Exterior</option>
|
||||||
|
<option>VIP</option>
|
||||||
|
</select>
|
||||||
|
<select class="filter-select">
|
||||||
|
<option>Grupo: Todos</option>
|
||||||
|
<option>1-2 pessoas</option>
|
||||||
|
<option>3-4 pessoas</option>
|
||||||
|
<option>5+ pessoas</option>
|
||||||
|
</select>
|
||||||
|
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||||
|
<button class="btn btn-ghost">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
Exportar CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STATUS TABS -->
|
||||||
|
<div class="status-tabs">
|
||||||
|
<div class="status-tab">Todas <span class="tab-count">24</span></div>
|
||||||
|
<div class="status-tab active">Pendentes <span class="tab-count">5</span></div>
|
||||||
|
<div class="status-tab">Confirmadas <span class="tab-count">12</span></div>
|
||||||
|
<div class="status-tab">Sentados <span class="tab-count">4</span></div>
|
||||||
|
<div class="status-tab">Concluídas <span class="tab-count">1</span></div>
|
||||||
|
<div class="status-tab">Canceladas <span class="tab-count">2</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TABLE -->
|
||||||
|
<div class="reservations-panel">
|
||||||
|
<div class="table-header">
|
||||||
|
<div class="th">Cliente</div>
|
||||||
|
<div class="th">Notas</div>
|
||||||
|
<div class="th">Hora</div>
|
||||||
|
<div class="th">Pax</div>
|
||||||
|
<div class="th">Mesa</div>
|
||||||
|
<div class="th">Estado</div>
|
||||||
|
<div class="th">Ações</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-body">
|
||||||
|
|
||||||
|
<!-- ROW 1 - Pending -->
|
||||||
|
<div class="table-row">
|
||||||
|
<div class="client-info">
|
||||||
|
<div class="client-name">M. Santos</div>
|
||||||
|
<div class="client-phone">+351 912 345 678</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted)">Alergia a frutos do mar</div>
|
||||||
|
<div class="time-cell">19:00</div>
|
||||||
|
<div class="pax-cell">3 pax</div>
|
||||||
|
<div class="mesa-cell">Mesa 6</div>
|
||||||
|
<div><span class="status-badge pending"><span class="status-dot-sm"></span>Pendente</span></div>
|
||||||
|
<div class="actions-cell">
|
||||||
|
<button class="action-btn confirm">Confirmar</button>
|
||||||
|
<button class="action-btn cancel">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ROW 2 - Pending -->
|
||||||
|
<div class="table-row">
|
||||||
|
<div class="client-info">
|
||||||
|
<div class="client-name">Alves Family</div>
|
||||||
|
<div class="client-phone">+351 961 234 567</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted)">Aniversário — bolo surpresa</div>
|
||||||
|
<div class="time-cell">20:30</div>
|
||||||
|
<div class="pax-cell">6 pax</div>
|
||||||
|
<div class="mesa-cell">Mesa 9</div>
|
||||||
|
<div><span class="status-badge pending"><span class="status-dot-sm"></span>Pendente</span></div>
|
||||||
|
<div class="actions-cell">
|
||||||
|
<button class="action-btn confirm">Confirmar</button>
|
||||||
|
<button class="action-btn cancel">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ROW 3 - Pending -->
|
||||||
|
<div class="table-row">
|
||||||
|
<div class="client-info">
|
||||||
|
<div class="client-name">T. Barbosa</div>
|
||||||
|
<div class="client-phone">+351 934 567 890</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted)">—</div>
|
||||||
|
<div class="time-cell">19:30</div>
|
||||||
|
<div class="pax-cell">2 pax</div>
|
||||||
|
<div class="mesa-cell">Mesa 11</div>
|
||||||
|
<div><span class="status-badge pending"><span class="status-dot-sm"></span>Pendente</span></div>
|
||||||
|
<div class="actions-cell">
|
||||||
|
<button class="action-btn confirm">Confirmar</button>
|
||||||
|
<button class="action-btn cancel">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ROW 4 - Confirmed -->
|
||||||
|
<div class="table-row">
|
||||||
|
<div class="client-info">
|
||||||
|
<div class="client-name">Ana Ferreira</div>
|
||||||
|
<div class="client-phone">+351 926 789 012</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted)">Preferência zona silenciosa</div>
|
||||||
|
<div class="time-cell">19:00</div>
|
||||||
|
<div class="pax-cell">4 pax</div>
|
||||||
|
<div class="mesa-cell">Mesa 2</div>
|
||||||
|
<div><span class="status-badge confirmed"><span class="status-dot-sm"></span>Confirmada</span></div>
|
||||||
|
<div class="actions-cell">
|
||||||
|
<button class="action-btn seat">Sentar</button>
|
||||||
|
<button class="action-btn cancel">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ROW 5 - Confirmed -->
|
||||||
|
<div class="table-row">
|
||||||
|
<div class="client-info">
|
||||||
|
<div class="client-name">Grupo Empresa XY</div>
|
||||||
|
<div class="client-phone">+351 213 456 789</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted)">Jantar de negócios</div>
|
||||||
|
<div class="time-cell">19:00</div>
|
||||||
|
<div class="pax-cell">8 pax</div>
|
||||||
|
<div class="mesa-cell">Mesa 12</div>
|
||||||
|
<div><span class="status-badge confirmed"><span class="status-dot-sm"></span>Confirmada</span></div>
|
||||||
|
<div class="actions-cell">
|
||||||
|
<button class="action-btn seat">Sentar</button>
|
||||||
|
<button class="action-btn cancel">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ROW 6 - Seated -->
|
||||||
|
<div class="table-row">
|
||||||
|
<div class="client-info">
|
||||||
|
<div class="client-name">Família Rodrigues</div>
|
||||||
|
<div class="client-phone">+351 917 654 321</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted)">Cadeira de bebé necessária</div>
|
||||||
|
<div class="time-cell">18:00</div>
|
||||||
|
<div class="pax-cell">5 pax</div>
|
||||||
|
<div class="mesa-cell">Mesa 4</div>
|
||||||
|
<div><span class="status-badge seated"><span class="status-dot-sm"></span>Sentados</span></div>
|
||||||
|
<div class="actions-cell">
|
||||||
|
<button class="action-btn" style="color:var(--text-muted)">Concluir</button>
|
||||||
|
<button class="action-btn" style="color:var(--status-noshow)">No-show</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ROW 7 - Cancelled -->
|
||||||
|
<div class="table-row" style="opacity: 0.5;">
|
||||||
|
<div class="client-info">
|
||||||
|
<div class="client-name">C. Marques</div>
|
||||||
|
<div class="client-phone">+351 963 258 741</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-muted)">Cancelou 2h antes</div>
|
||||||
|
<div class="time-cell">20:00</div>
|
||||||
|
<div class="pax-cell">2 pax</div>
|
||||||
|
<div class="mesa-cell">Mesa 5</div>
|
||||||
|
<div><span class="status-badge cancelled"><span class="status-dot-sm"></span>Cancelada</span></div>
|
||||||
|
<div class="actions-cell">
|
||||||
|
<button class="action-btn">Ver</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="mockup-label">MOCKUP v1.0 — RESERVAMESA RESERVAS</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
554
mockups/03_mesas.html
Normal file
554
mockups/03_mesas.html
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ReservaMesa — Mapa de Mesas Mockup</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0F0E0C;
|
||||||
|
--bg-secondary: #1A1814;
|
||||||
|
--bg-tertiary: #252219;
|
||||||
|
--bg-hover: #2E2A22;
|
||||||
|
--brand: #D4891A;
|
||||||
|
--brand-light: #E8A832;
|
||||||
|
--brand-muted: #3D2E0F;
|
||||||
|
--status-confirmed: #10B981;
|
||||||
|
--status-pending: #F59E0B;
|
||||||
|
--status-seated: #3B82F6;
|
||||||
|
--status-cancelled: #EF4444;
|
||||||
|
--text-primary: #F5F0E8;
|
||||||
|
--text-secondary: #A09880;
|
||||||
|
--text-muted: #6B6355;
|
||||||
|
--border: #2A261E;
|
||||||
|
--border-strong: #3D3828;
|
||||||
|
--font-display: 'Playfair Display', serif;
|
||||||
|
--font-body: 'DM Sans', sans-serif;
|
||||||
|
--font-mono: 'DM Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { font-family: var(--font-body); background: var(--bg-primary); color: var(--text-primary); display: flex; height: 100vh; overflow: hidden; font-size: 14px; }
|
||||||
|
|
||||||
|
.sidebar { width: 220px; flex-shrink: 0; background: var(--bg-secondary); border-right: 1px solid var(--border); display: flex; flex-direction: column; }
|
||||||
|
.sidebar-logo { padding: 20px 20px 16px; border-bottom: 1px solid var(--border); }
|
||||||
|
.logo-mark { font-family: var(--font-display); font-size: 18px; font-weight: 700; color: var(--brand-light); }
|
||||||
|
.logo-sub { font-size: 10px; color: var(--text-muted); letter-spacing: 1.5px; text-transform: uppercase; }
|
||||||
|
.sidebar-restaurant { padding: 12px 16px; margin: 10px; background: var(--bg-tertiary); border-radius: 8px; border: 1px solid var(--border); }
|
||||||
|
.restaurant-name { font-weight: 600; font-size: 12px; }
|
||||||
|
.restaurant-status { font-size: 10px; color: var(--status-confirmed); display: flex; align-items: center; gap: 4px; margin-top: 2px; }
|
||||||
|
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
|
.nav-section { padding: 6px 10px 0; flex: 1; }
|
||||||
|
.nav-label { font-size: 9px; color: var(--text-muted); letter-spacing: 1.5px; text-transform: uppercase; padding: 8px 8px 4px; }
|
||||||
|
.nav-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 7px; color: var(--text-secondary); cursor: pointer; font-size: 13px; font-weight: 500; margin-bottom: 1px; }
|
||||||
|
.nav-item.active { background: var(--brand-muted); color: var(--brand-light); }
|
||||||
|
.nav-icon { width: 16px; height: 16px; }
|
||||||
|
.nav-badge { margin-left: auto; background: var(--brand); color: #000; font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 10px; }
|
||||||
|
.sidebar-footer { padding: 12px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
|
||||||
|
.avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--brand-muted); border: 2px solid var(--brand); display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: var(--brand-light); }
|
||||||
|
.footer-name { font-size: 12px; font-weight: 600; }
|
||||||
|
.footer-role { font-size: 10px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.topbar { height: 56px; background: var(--bg-secondary); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 24px; gap: 16px; flex-shrink: 0; }
|
||||||
|
.topbar-title { font-family: var(--font-display); font-size: 18px; font-weight: 600; flex: 1; }
|
||||||
|
.topbar-date { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); }
|
||||||
|
.btn { display: flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 7px; font-size: 12px; font-weight: 600; cursor: pointer; border: none; font-family: var(--font-body); }
|
||||||
|
.btn-primary { background: var(--brand); color: #000; }
|
||||||
|
.btn-ghost { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-strong); }
|
||||||
|
|
||||||
|
/* CONTENT */
|
||||||
|
.content { flex: 1; display: flex; overflow: hidden; }
|
||||||
|
|
||||||
|
/* FLOOR PLAN AREA */
|
||||||
|
.floorplan-area { flex: 1; padding: 20px; overflow: auto; position: relative; }
|
||||||
|
|
||||||
|
.zone-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-label::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.floor-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tables-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TABLE SHAPES */
|
||||||
|
.table-item {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-item:hover { transform: scale(1.04); }
|
||||||
|
|
||||||
|
.table-rect {
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-rect.free {
|
||||||
|
background: rgba(16,185,129,0.08);
|
||||||
|
border-color: rgba(16,185,129,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-rect.reserved {
|
||||||
|
background: rgba(245,158,11,0.08);
|
||||||
|
border-color: rgba(245,158,11,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-rect.occupied {
|
||||||
|
background: rgba(59,130,246,0.08);
|
||||||
|
border-color: rgba(59,130,246,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-rect.selected {
|
||||||
|
box-shadow: 0 0 0 3px var(--brand);
|
||||||
|
border-color: var(--brand);
|
||||||
|
background: var(--brand-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-num {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-rect.free .table-num { color: var(--status-confirmed); }
|
||||||
|
.table-rect.reserved .table-num { color: var(--status-pending); }
|
||||||
|
.table-rect.occupied .table-num { color: var(--status-seated); }
|
||||||
|
.table-rect.selected .table-num { color: var(--brand-light); }
|
||||||
|
|
||||||
|
.table-pax {
|
||||||
|
font-size: 10px;
|
||||||
|
margin-top: 2px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-rect.free .table-pax { color: var(--status-confirmed); }
|
||||||
|
.table-rect.reserved .table-pax { color: var(--status-pending); }
|
||||||
|
.table-rect.occupied .table-pax { color: var(--status-seated); }
|
||||||
|
|
||||||
|
/* Chairs decoration */
|
||||||
|
.chair {
|
||||||
|
position: absolute;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* LEGEND */
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot { width: 10px; height: 10px; border-radius: 3px; }
|
||||||
|
.legend-dot.free { background: rgba(16,185,129,0.3); border: 1.5px solid var(--status-confirmed); }
|
||||||
|
.legend-dot.reserved { background: rgba(245,158,11,0.3); border: 1.5px solid var(--status-pending); }
|
||||||
|
.legend-dot.occupied { background: rgba(59,130,246,0.3); border: 1.5px solid var(--status-seated); }
|
||||||
|
|
||||||
|
/* DETAIL PANEL */
|
||||||
|
.detail-panel {
|
||||||
|
width: 280px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesa-status-indicator {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mesa-status-indicator.reserved { background: rgba(245,158,11,0.15); color: var(--status-pending); }
|
||||||
|
|
||||||
|
.detail-body { padding: 16px 18px; flex: 1; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.8px; }
|
||||||
|
.detail-value { font-size: 14px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.detail-value.mono { font-family: var(--font-mono); color: var(--brand-light); }
|
||||||
|
|
||||||
|
.detail-divider { height: 1px; background: var(--border); margin: 4px 0; }
|
||||||
|
|
||||||
|
.client-card {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-card-name { font-size: 14px; font-weight: 700; }
|
||||||
|
.client-card-phone { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); margin-top: 2px; }
|
||||||
|
.client-card-note { font-size: 11px; color: var(--brand); margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.detail-actions { padding: 14px 18px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 4px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
|
||||||
|
|
||||||
|
.mockup-label { position: fixed; bottom: 12px; right: 16px; font-size: 10px; color: var(--text-muted); font-family: var(--font-mono); letter-spacing: 1px; opacity: 0.5; }
|
||||||
|
svg { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<div class="logo-mark">ReservaMesa</div>
|
||||||
|
<div class="logo-sub">Dashboard</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 10px;">
|
||||||
|
<div class="sidebar-restaurant">
|
||||||
|
<div class="restaurant-name">Restaurante Solar</div>
|
||||||
|
<div class="restaurant-status"><span class="dot"></span> Aberto agora</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="nav-section">
|
||||||
|
<div class="nav-label">Principal</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||||
|
Dashboard
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><path d="m9 16 2 2 4-4"/></svg>
|
||||||
|
Reservas
|
||||||
|
<span class="nav-badge">12</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item active">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18M15 3v18"/></svg>
|
||||||
|
Mesas
|
||||||
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||||
|
Calendário
|
||||||
|
</div>
|
||||||
|
<div class="nav-label" style="margin-top:8px">Análise</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg>
|
||||||
|
Analytics
|
||||||
|
</div>
|
||||||
|
<div class="nav-label" style="margin-top:8px">Gestão</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 0-14.14 0"/></svg>
|
||||||
|
Configurações
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="avatar">JS</div>
|
||||||
|
<div style="flex:1"><div class="footer-name">João Silva</div><div class="footer-role">Gestor</div></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="topbar-title">Mapa de Mesas</div>
|
||||||
|
<div class="topbar-date">Quarta, 6 Mai 2026 · 19:00</div>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<button class="btn btn-ghost">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
Editar Layout
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
Adicionar Mesa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LEGEND -->
|
||||||
|
<div class="legend">
|
||||||
|
<span style="font-size:11px;color:var(--text-muted);font-weight:600;margin-right:4px">Estado das Mesas:</span>
|
||||||
|
<div class="legend-item"><div class="legend-dot free"></div>Livre (5)</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot reserved"></div>Reservada (6)</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot occupied"></div>Ocupada (5)</div>
|
||||||
|
<div style="margin-left:auto;font-size:11px;color:var(--text-muted)">Clique numa mesa para ver detalhes</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
|
||||||
|
<!-- FLOOR PLAN -->
|
||||||
|
<div class="floorplan-area">
|
||||||
|
|
||||||
|
<!-- Interior Zone -->
|
||||||
|
<div class="floor-section">
|
||||||
|
<div class="zone-label">Interior</div>
|
||||||
|
<div class="tables-layout">
|
||||||
|
|
||||||
|
<!-- Table 1 - Occupied (2p) -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect occupied" style="width:72px;height:72px">
|
||||||
|
<div class="table-num">1</div>
|
||||||
|
<div class="table-pax">2 pax</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table 2 - Free (4p) -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect free" style="width:88px;height:72px">
|
||||||
|
<div class="table-num">2</div>
|
||||||
|
<div class="table-pax">4 pax</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table 3 - Reserved (selected) -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect selected" style="width:72px;height:72px">
|
||||||
|
<div class="table-num" style="color:var(--brand-light)">3</div>
|
||||||
|
<div class="table-pax" style="color:var(--brand)">2 pax</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table 4 - Occupied (6p) - long table -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect occupied" style="width:140px;height:72px">
|
||||||
|
<div class="table-num">4</div>
|
||||||
|
<div class="table-pax">6 pax</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table 5 - Free (4p) -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect free" style="width:88px;height:72px">
|
||||||
|
<div class="table-num">5</div>
|
||||||
|
<div class="table-pax">4 pax</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table 6 - Reserved (3p) -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect reserved" style="width:80px;height:72px">
|
||||||
|
<div class="table-num">6</div>
|
||||||
|
<div class="table-pax">3 pax</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table 7 - Occupied (2p) -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect occupied" style="width:72px;height:72px">
|
||||||
|
<div class="table-num">7</div>
|
||||||
|
<div class="table-pax">2 pax</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table 8 - Free (4p) -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect free" style="width:88px;height:72px">
|
||||||
|
<div class="table-num">8</div>
|
||||||
|
<div class="table-pax">4 pax</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Exterior Zone -->
|
||||||
|
<div class="floor-section">
|
||||||
|
<div class="zone-label">Exterior / Esplanada</div>
|
||||||
|
<div class="tables-layout">
|
||||||
|
|
||||||
|
<!-- Table 9 - Reserved (6p) -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect reserved" style="width:120px;height:72px">
|
||||||
|
<div class="table-num">9</div>
|
||||||
|
<div class="table-pax">6 pax</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table 10 - Occupied (4p) -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect occupied" style="width:88px;height:72px">
|
||||||
|
<div class="table-num">10</div>
|
||||||
|
<div class="table-pax">4 pax</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table 11 - Reserved (2p) -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect reserved" style="width:72px;height:72px">
|
||||||
|
<div class="table-num">11</div>
|
||||||
|
<div class="table-pax">2 pax</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table 12 - Occupied (8p) - banquet -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect occupied" style="width:180px;height:80px">
|
||||||
|
<div class="table-num">12</div>
|
||||||
|
<div class="table-pax">8 pax · Grupo Empresa XY</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VIP Zone -->
|
||||||
|
<div class="floor-section">
|
||||||
|
<div class="zone-label">VIP / Privado</div>
|
||||||
|
<div class="tables-layout">
|
||||||
|
|
||||||
|
<!-- Table 13 - Free (4p) -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect free" style="width:88px;height:72px; border-style: dashed;">
|
||||||
|
<div class="table-num">13</div>
|
||||||
|
<div class="table-pax">4 pax</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table 14 - Reserved (4p) -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect reserved" style="width:88px;height:72px; border-style: dashed;">
|
||||||
|
<div class="table-num">14</div>
|
||||||
|
<div class="table-pax">4 pax</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table 15 - Free (2p) -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect free" style="width:72px;height:72px; border-style: dashed;">
|
||||||
|
<div class="table-num">15</div>
|
||||||
|
<div class="table-pax">2 pax</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table 16 - Occupied (6p) -->
|
||||||
|
<div class="table-item">
|
||||||
|
<div class="table-rect occupied" style="width:120px;height:72px; border-style: dashed;">
|
||||||
|
<div class="table-num">16</div>
|
||||||
|
<div class="table-pax">6 pax</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DETAIL PANEL - shown when table selected -->
|
||||||
|
<div class="detail-panel">
|
||||||
|
<div class="detail-header">
|
||||||
|
<div class="detail-title">Mesa 3</div>
|
||||||
|
<div class="mesa-status-indicator reserved">Reservada</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-body">
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">Zona</div>
|
||||||
|
<div class="detail-value">Interior</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">Capacidade</div>
|
||||||
|
<div class="detail-value">2 pessoas</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-divider"></div>
|
||||||
|
|
||||||
|
<div class="detail-label" style="margin-bottom: 6px;">Reserva Atual</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">Hora</div>
|
||||||
|
<div class="detail-value mono">21:30</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="client-card">
|
||||||
|
<div class="client-card-name">Sofia Lopes</div>
|
||||||
|
<div class="client-card-phone">+351 925 147 369</div>
|
||||||
|
<div class="client-card-note">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline;margin-right:4px"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
Jantar especial — aniversário
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">Grupo</div>
|
||||||
|
<div class="detail-value">2 pessoas</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button class="btn btn-primary" style="width:100%;justify-content:center;font-size:12px;padding:9px">
|
||||||
|
Confirmar Reserva
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost" style="width:100%;justify-content:center;font-size:12px;padding:9px">
|
||||||
|
Ver Detalhe Completo
|
||||||
|
</button>
|
||||||
|
<button class="btn" style="width:100%;justify-content:center;font-size:12px;padding:9px;color:var(--status-cancelled);border:1px solid rgba(239,68,68,0.3);">
|
||||||
|
Cancelar Reserva
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="mockup-label">MOCKUP v1.0 — RESERVAMESA MESAS</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
reserva-mesa-dashboard/app/(auth)/layout.tsx
Normal file
11
reserva-mesa-dashboard/app/(auth)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
reserva-mesa-dashboard/app/(auth)/login/page.tsx
Normal file
89
reserva-mesa-dashboard/app/(auth)/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
reserva-mesa-dashboard/app/(auth)/register/page.tsx
Normal file
137
reserva-mesa-dashboard/app/(auth)/register/page.tsx
Normal 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">
|
||||||
|
Já tem conta?{" "}
|
||||||
|
<Link href="/login" className="text-primary hover:underline">
|
||||||
|
Iniciar sessão
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
reserva-mesa-dashboard/app/(dashboard)/configuracoes/page.tsx
Normal file
159
reserva-mesa-dashboard/app/(dashboard)/configuracoes/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
reserva-mesa-dashboard/app/(dashboard)/equipa/page.tsx
Normal file
164
reserva-mesa-dashboard/app/(dashboard)/equipa/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
reserva-mesa-dashboard/app/(dashboard)/historico/page.tsx
Normal file
80
reserva-mesa-dashboard/app/(dashboard)/historico/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
reserva-mesa-dashboard/app/(dashboard)/layout.tsx
Normal file
38
reserva-mesa-dashboard/app/(dashboard)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
reserva-mesa-dashboard/app/(dashboard)/lista-espera/page.tsx
Normal file
126
reserva-mesa-dashboard/app/(dashboard)/lista-espera/page.tsx
Normal 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 há reservas pendentes de aprovação neste momento.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AssignTableModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
reserva={selectedReserva}
|
||||||
|
mesas={mesas}
|
||||||
|
onAssign={handleAssign}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
reserva-mesa-dashboard/app/(dashboard)/mesas/page.tsx
Normal file
150
reserva-mesa-dashboard/app/(dashboard)/mesas/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
reserva-mesa-dashboard/app/(dashboard)/page.tsx
Normal file
182
reserva-mesa-dashboard/app/(dashboard)/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
reserva-mesa-dashboard/app/(dashboard)/reservas/page.tsx
Normal file
137
reserva-mesa-dashboard/app/(dashboard)/reservas/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
reserva-mesa-dashboard/app/globals.css
Normal file
50
reserva-mesa-dashboard/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
reserva-mesa-dashboard/app/layout.tsx
Normal file
46
reserva-mesa-dashboard/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
reserva-mesa-dashboard/components.json
Normal file
20
reserva-mesa-dashboard/components.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
reserva-mesa-dashboard/components/auth/AuthGuard.tsx
Normal file
35
reserva-mesa-dashboard/components/auth/AuthGuard.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
reserva-mesa-dashboard/components/dashboard/Header.tsx
Normal file
26
reserva-mesa-dashboard/components/dashboard/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
reserva-mesa-dashboard/components/dashboard/MobileNav.tsx
Normal file
63
reserva-mesa-dashboard/components/dashboard/MobileNav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
reserva-mesa-dashboard/components/dashboard/Sidebar.tsx
Normal file
78
reserva-mesa-dashboard/components/dashboard/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
reserva-mesa-dashboard/components/dashboard/UserNav.tsx
Normal file
20
reserva-mesa-dashboard/components/dashboard/UserNav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
reserva-mesa-dashboard/components/ui/button.tsx
Normal file
52
reserva-mesa-dashboard/components/ui/button.tsx
Normal 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 }
|
||||||
75
reserva-mesa-dashboard/components/ui/card.tsx
Normal file
75
reserva-mesa-dashboard/components/ui/card.tsx
Normal 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 }
|
||||||
24
reserva-mesa-dashboard/components/ui/input.tsx
Normal file
24
reserva-mesa-dashboard/components/ui/input.tsx
Normal 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 }
|
||||||
25
reserva-mesa-dashboard/components/ui/label.tsx
Normal file
25
reserva-mesa-dashboard/components/ui/label.tsx
Normal 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 }
|
||||||
39
reserva-mesa-dashboard/components/ui/switch.tsx
Normal file
39
reserva-mesa-dashboard/components/ui/switch.tsx
Normal 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 };
|
||||||
75
reserva-mesa-dashboard/components/ui/toast.tsx
Normal file
75
reserva-mesa-dashboard/components/ui/toast.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
reserva-mesa-dashboard/contexts/AuthContext.tsx
Normal file
103
reserva-mesa-dashboard/contexts/AuthContext.tsx
Normal 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);
|
||||||
3
reserva-mesa-dashboard/hooks/useAuth.ts
Normal file
3
reserva-mesa-dashboard/hooks/useAuth.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
|
export { useAuth };
|
||||||
94
reserva-mesa-dashboard/hooks/useMesas.ts
Normal file
94
reserva-mesa-dashboard/hooks/useMesas.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
100
reserva-mesa-dashboard/hooks/useReservas.ts
Normal file
100
reserva-mesa-dashboard/hooks/useReservas.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
76
reserva-mesa-dashboard/hooks/useStaff.ts
Normal file
76
reserva-mesa-dashboard/hooks/useStaff.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
23
reserva-mesa-dashboard/lib/firebase.ts
Normal file
23
reserva-mesa-dashboard/lib/firebase.ts
Normal 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;
|
||||||
6
reserva-mesa-dashboard/lib/utils.ts
Normal file
6
reserva-mesa-dashboard/lib/utils.ts
Normal 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))
|
||||||
|
}
|
||||||
14
reserva-mesa-dashboard/middleware.ts
Normal file
14
reserva-mesa-dashboard/middleware.ts
Normal 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
5
reserva-mesa-dashboard/next-env.d.ts
vendored
Normal 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.
|
||||||
4
reserva-mesa-dashboard/next.config.js
Normal file
4
reserva-mesa-dashboard/next.config.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
7565
reserva-mesa-dashboard/package-lock.json
generated
Normal file
7565
reserva-mesa-dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
reserva-mesa-dashboard/package.json
Normal file
41
reserva-mesa-dashboard/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
reserva-mesa-dashboard/postcss.config.js
Normal file
6
reserva-mesa-dashboard/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
85
reserva-mesa-dashboard/tailwind.config.ts
Normal file
85
reserva-mesa-dashboard/tailwind.config.ts
Normal 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
|
||||||
26
reserva-mesa-dashboard/tsconfig.json
Normal file
26
reserva-mesa-dashboard/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
9
reserva-mesa-dashboard/types/mesa.ts
Normal file
9
reserva-mesa-dashboard/types/mesa.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export type MesaEstado = "Livre" | "Ocupada" | "Reservada";
|
||||||
|
|
||||||
|
export interface Mesa {
|
||||||
|
id: string;
|
||||||
|
numero: number;
|
||||||
|
capacidade: number;
|
||||||
|
estado: MesaEstado;
|
||||||
|
restauranteEmail: string;
|
||||||
|
}
|
||||||
12
reserva-mesa-dashboard/types/reserva.ts
Normal file
12
reserva-mesa-dashboard/types/reserva.ts
Normal 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;
|
||||||
|
}
|
||||||
8
reserva-mesa-dashboard/types/staff.ts
Normal file
8
reserva-mesa-dashboard/types/staff.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface Staff {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
email: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
restauranteEmail: string;
|
||||||
|
}
|
||||||
18
reserva-mesa-dashboard/types/user.ts
Normal file
18
reserva-mesa-dashboard/types/user.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user