Add project report and implement calendar week view component

This commit is contained in:
2026-01-13 14:53:41 +00:00
parent 3c7190bca4
commit 58e5889b89
6 changed files with 1107 additions and 184 deletions

423
RELATORIO_PROJETO.md Normal file
View File

@@ -0,0 +1,423 @@
# 📊 Relatório do Projeto Smart Agenda
**Data:** Janeiro 2025
**Versão:** 1.0.0
**Status:** Em Desenvolvimento
---
## 📋 Visão Geral
O **Smart Agenda** é uma aplicação completa para gestão de agendamentos de barbearias, disponível em duas versões:
- **Mobile:** React Native/Expo (aplicação nativa)
- **Web:** React + TypeScript + Vite (aplicação web responsiva)
---
## 🏗️ Arquitetura do Projeto
### Estrutura de Pastas
```
SmartAgendaMobile/
├── src/ # Aplicação Mobile (React Native/Expo)
│ ├── components/ # Componentes UI reutilizáveis
│ ├── context/ # Context API (estado global)
│ ├── data/ # Dados mock
│ ├── lib/ # Utilitários
│ ├── navigation/ # Configuração de navegação
│ └── pages/ # Páginas da aplicação
└── web/ # Aplicação Web (React + Vite)
└── src/
├── components/ # Componentes UI
├── context/ # Context API
├── data/ # Dados mock
├── lib/ # Utilitários
├── pages/ # Páginas
└── routes.tsx # Rotas da aplicação
```
---
## 🎯 Funcionalidades Implementadas
### 👤 Para Clientes
1. **Exploração de Barbearias**
- Lista de barbearias disponíveis
- Visualização de detalhes (serviços, produtos, barbeiros)
- Avaliações e informações de localização
2. **Sistema de Agendamento**
- Seleção de serviço
- Escolha de barbeiro
- Seleção de data e horário
- Validação de disponibilidade em tempo real
- Histórico de agendamentos
3. **Carrinho de Compras**
- Adicionar produtos ao carrinho
- Agrupamento por barbearia
- Checkout e finalização de pedidos
4. **Perfil do Usuário**
- Visualização de agendamentos
- Histórico de pedidos
- Gestão de conta
### 🏪 Para Barbearias
1. **Dashboard Completo**
- Métricas de faturamento
- Gráficos de receita (Recharts)
- Estatísticas de agendamentos e pedidos
- Filtros por período (hoje, semana, mês, ano)
2. **Gestão de Agendamentos**
- Visualização de todos os agendamentos
- Alteração de status (pendente, confirmado, concluído, cancelado)
- Filtros por status
3. **Gestão de Pedidos**
- Visualização de pedidos de produtos
- Alteração de status
- Histórico completo
4. **CRUD de Serviços**
- Criar, editar e excluir serviços
- Definir preço e duração
- Associar barbeiros aos serviços
5. **CRUD de Produtos**
- Criar, editar e excluir produtos
- Controlo de stock
- Alertas de stock baixo
6. **CRUD de Barbeiros**
- Adicionar e remover barbeiros
- Definir especialidades
- Gestão de horários e disponibilidade
---
## 🎨 Design e UI
### Paleta de Cores Atual
- **Primária:** Indigo/Blue (profissional, moderno)
- **Secundária:** Slate (neutro)
- **Background:** Gradientes suaves
- **Status:** Sistema de badges coloridos
### Componentes UI Criados
1. **Componentes Base:**
- `Button` - Botões com variantes (solid, outline, ghost, danger)
- `Card` - Cards com hover effects
- `Input` - Inputs estilizados
- `Badge` - Badges coloridos
- `Tabs` - Sistema de abas
- `Chip` - Chips selecionáveis
- `Dialog` - Modais
2. **Componentes Específicos:**
- `ShopCard` - Card de barbearia
- `ServiceList` - Lista de serviços
- `ProductList` - Lista de produtos
- `CartPanel` - Painel do carrinho
- `DashboardCards` - Cards do dashboard
- `Header` - Cabeçalho com navegação
- `Shell` - Layout principal
### Landing Page
Página inicial desenvolvida com:
- **Hero Section** - Seção principal com CTA
- **Features Grid** - 6 funcionalidades principais
- **Como Funciona** - Passo a passo em 3 etapas
- **Barbearias em Destaque** - Cards de barbearias
- **Benefícios** - Mobile-first e aumento de receita
- **Depoimentos** - Testemunhos de clientes
- **CTA Final** - Chamada para ação
---
## 🔧 Tecnologias Utilizadas
### Mobile (React Native/Expo)
- **Expo** ~54.0.27
- **React Native** 0.81.5
- **React Navigation** - Navegação
- **AsyncStorage** - Persistência
- **Nanoid** - Geração de IDs
- **TypeScript** - Tipagem
### Web (React + Vite)
- **React** 18.3.1
- **TypeScript** 5.6.3
- **Vite** 5.4.10
- **React Router** v6 - Roteamento
- **Tailwind CSS** 3.4.14 - Estilização
- **Recharts** 2.12.7 - Gráficos
- **Lucide React** 0.473.0 - Ícones
- **Zustand** 4.5.4 - Estado (não utilizado, Context API em uso)
- **Date-fns** 4.1.0 - Manipulação de datas
- **Nanoid** 5.0.7 - Geração de IDs
---
## 💾 Gestão de Estado
### Context API
- Estado global centralizado em `AppContext`
- Persistência automática em `localStorage` (web)
- Funções para:
- Autenticação (login, logout, registro)
- Gestão de carrinho
- Criação de agendamentos e pedidos
- CRUD completo de serviços, produtos e barbeiros
- Atualização de status
### Dados Mock
- 2 usuários demo (cliente e barbearia)
- 2 barbearias com serviços, produtos e barbeiros
- Sistema de dados persistente
---
## 🌐 Rotas da Aplicação Web
1. `/` - Landing Page
2. `/login` - Login
3. `/registo` - Registro
4. `/explorar` - Explorar barbearias
5. `/barbearia/:id` - Detalhes da barbearia
6. `/agendar/:id` - Agendamento
7. `/carrinho` - Carrinho de compras
8. `/perfil` - Perfil do usuário
9. `/painel` - Dashboard da barbearia
---
## 🔐 Sistema de Autenticação
### Credenciais Demo
- **Cliente:** `cliente@demo.com` / `123`
- **Barbearia:** `barber@demo.com` / `123`
### Funcionalidades
- Login com validação
- Registro de novos usuários
- Registro de barbearias (cria barbearia automaticamente)
- Logout
- Redirecionamento automático baseado em role
---
## 💰 Sistema de Moeda
- **Moeda Atual:** EUR (Euros)
- **Locale:** pt-PT
- Formatação automática em toda a aplicação
- Função `currency()` centralizada em `lib/format.ts`
---
## 📱 Responsividade
- Design **mobile-first**
- Breakpoints Tailwind (md, lg)
- Layout adaptativo
- Navegação mobile com menu hambúrguer
- Componentes responsivos
---
## 📊 Funcionalidades do Dashboard
### Métricas Principais
- Faturamento total
- Agendamentos do período
- Pedidos do período
- Produtos com stock baixo
### Gráficos
- Gráfico de barras de receita por período
- Visualização de tendências
### Gestão
- Tabs para navegação entre seções
- Filtros por período
- Ações rápidas (CRUD)
---
## 🛠️ Melhorias Realizadas (Nesta Sessão)
1. **Landing Page Desenvolvida**
- Página inicial completa e profissional
- Múltiplas seções com conteúdo rico
- Design moderno e atraente
2. **Paleta de Cores Atualizada**
- Mudança de Amber para Indigo/Blue
- Atualização em todos os componentes
- Documentação de opções de cores criada
3. **Correções Técnicas**
- Correção de erro TypeScript em `cn.ts`
- Configuração do servidor Vite para acesso externo
- Ajustes de lint
4. **Documentação**
- Criação de `OPCOES_CORES.md` com 6 paletas alternativas
- Documentação de como aplicar novas cores
5. **Moeda Atualizada**
- Mudança de BRL (Reais) para EUR (Euros)
- Formatação atualizada em toda a aplicação
---
## 📝 Arquivos de Documentação
1. **README.md** - Documentação principal do projeto mobile
2. **web/README.md** - Documentação da aplicação web
3. **web/OPCOES_CORES.md** - Opções de paletas de cores
4. **CONECTAR_ANDROID.md** - Instruções para conectar Android
5. **RELATORIO_PROJETO.md** - Este relatório
---
## 🚀 Como Executar
### Aplicação Web
```bash
cd web
npm install
npm run dev
# Acesse http://localhost:5173
```
### Aplicação Mobile
```bash
npm install
npm start
# Escolha a plataforma (a/i/w)
```
---
## 📈 Estatísticas do Projeto
### Arquivos Criados
- **Páginas:** 9 (Landing, Login, Register, Explore, ShopDetails, Booking, Cart, Profile, Dashboard)
- **Componentes UI:** 7 componentes base
- **Componentes Específicos:** 5 componentes
- **Context:** 1 (AppContext completo)
- **Utilitários:** 2 (format, storage)
### Linhas de Código (Estimativa)
- Aproximadamente 3.000+ linhas de código TypeScript/TSX
- Componentes bem estruturados e reutilizáveis
---
## ✅ Status das Funcionalidades
### ✅ Completas
- [x] Autenticação (login/registro)
- [x] Exploração de barbearias
- [x] Sistema de agendamento
- [x] Carrinho de compras
- [x] Dashboard completo
- [x] CRUD de serviços
- [x] CRUD de produtos
- [x] CRUD de barbeiros
- [x] Gestão de agendamentos
- [x] Gestão de pedidos
- [x] Landing page
- [x] Design responsivo
- [x] Persistência de dados
### 🔄 Em Desenvolvimento
- [ ] Melhorias no fluxo de agendamento (separação por etapas)
- [ ] Validações adicionais
- [ ] Testes automatizados
### 📋 Futuras Melhorias
- [ ] Integração com API real
- [ ] Sistema de notificações
- [ ] Pagamentos online
- [ ] Sistema de avaliações
- [ ] Chat/suporte
- [ ] App mobile nativo completo
---
## 🎨 Opções de Cores Disponíveis
Documentadas em `web/OPCOES_CORES.md`:
1. Azul/Indigo (atual) ✅
2. Âmbar/Amarelo
3. Verde/Emerald
4. Roxo/Violet
5. Vermelho/Rose
6. Ciano/Sky
7. Laranja/Orange
---
## 🔍 Próximos Passos Sugeridos
1. **Melhorar Fluxo de Agendamento**
- Separar etapas (serviço → barbeiro → data/hora)
- Melhorar UX do processo
2. **Validações**
- Validação de formulários
- Mensagens de erro mais claras
- Validação de horários disponíveis
3. **Testes**
- Testes unitários
- Testes de integração
- Testes E2E
4. **Performance**
- Otimização de imagens
- Lazy loading
- Code splitting
5. **Acessibilidade**
- ARIA labels
- Navegação por teclado
- Contraste de cores
---
## 📞 Informações Técnicas
### Portas Utilizadas
- **Web:** 5173 (Vite dev server)
- **Mobile:** Expo padrão
### Estrutura de Dados
- Tipos TypeScript bem definidos
- Interfaces claras e documentadas
- Validação de tipos em tempo de compilação
### Persistência
- **Web:** localStorage
- **Mobile:** AsyncStorage
---
## 📄 Licença
Projeto privado - Todos os direitos reservados
---
**Última Atualização:** Janeiro 2025
**Versão do Relatório:** 1.0

View File

@@ -0,0 +1,269 @@
import { useMemo, useState, useEffect } from 'react';
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, RefreshCw } from 'lucide-react';
import { Appointment, BarberShop } from '../types';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
type CalendarWeekViewProps = {
week: Date;
appointments: Appointment[];
shop: BarberShop;
onWeekChange: (date: Date) => void;
onStatusChange: (id: string, status: Appointment['status']) => void;
};
export const CalendarWeekView = ({
week,
appointments,
shop,
onWeekChange,
onStatusChange,
}: CalendarWeekViewProps) => {
const [currentTime, setCurrentTime] = useState(new Date());
// Atualizar hora atual a cada minuto
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(new Date());
}, 60000);
return () => clearInterval(interval);
}, []);
// Obter dias da semana
const weekDays = useMemo(() => {
const startOfWeek = new Date(week);
const day = startOfWeek.getDay();
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1); // Segunda-feira
startOfWeek.setDate(diff);
const days = [];
for (let i = 0; i < 7; i++) {
const date = new Date(startOfWeek);
date.setDate(startOfWeek.getDate() + i);
days.push(date);
}
return days;
}, [week]);
// Gerar slots de horário (08:00 até 18:00, intervalos de 30 minutos)
const timeSlots = useMemo(() => {
const slots: string[] = [];
for (let hour = 8; hour <= 18; hour++) {
slots.push(`${hour.toString().padStart(2, '0')}:00`);
if (hour < 18) {
slots.push(`${hour.toString().padStart(2, '0')}:30`);
}
}
return slots;
}, []);
// Formatar data para YYYY-MM-DD
const formatDateKey = (date: Date) => {
return date.toISOString().split('T')[0];
};
// Obter agendamentos para um dia e horário específicos
const getAppointmentsAtSlot = (date: Date, timeSlot: string) => {
const dateKey = formatDateKey(date);
return appointments.filter((apt) => {
const aptDate = apt.date.split(' ')[0];
const aptTime = apt.date.split(' ')[1]?.substring(0, 5);
return aptDate === dateKey && aptTime === timeSlot;
});
};
// Navegação de semana
const goToPreviousWeek = () => {
const newDate = new Date(week);
newDate.setDate(newDate.getDate() - 7);
onWeekChange(newDate);
};
const goToNextWeek = () => {
const newDate = new Date(week);
newDate.setDate(newDate.getDate() + 7);
onWeekChange(newDate);
};
const goToToday = () => {
onWeekChange(new Date());
};
// Verificar se é hoje
const isToday = (date: Date) => {
const today = new Date();
return (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
};
// Obter posição da linha de hora atual
const getCurrentTimePosition = (date: Date) => {
if (!isToday(date)) return null;
const now = currentTime;
const hours = now.getHours();
const minutes = now.getMinutes();
const totalMinutes = hours * 60 + minutes;
const startMinutes = 8 * 60; // 08:00
const slotIndex = Math.floor((totalMinutes - startMinutes) / 30);
if (slotIndex < 0 || slotIndex >= timeSlots.length) return null;
return slotIndex;
};
// Nomes dos dias da semana
const dayNames = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'];
const dayNamesFull = ['Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado', 'Domingo'];
return (
<div className="space-y-4">
{/* Header com navegação */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={goToPreviousWeek}>
<ChevronLeft size={18} />
</Button>
<Button variant="outline" size="sm" onClick={goToToday}>
Hoje
</Button>
<Button variant="outline" size="sm" onClick={goToNextWeek}>
<ChevronRight size={18} />
</Button>
<div className="ml-4">
<h3 className="text-lg font-bold text-slate-900">
{weekDays[0].toLocaleDateString('pt-PT', { day: 'numeric', month: 'long' })} -{' '}
{weekDays[6].toLocaleDateString('pt-PT', { day: 'numeric', month: 'long' })}
</h3>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm">
<ZoomOut size={18} />
</Button>
<Button variant="ghost" size="sm">
<ZoomIn size={18} />
</Button>
<Button variant="ghost" size="sm">
<RefreshCw size={18} />
</Button>
</div>
</div>
{/* Calendário */}
<div className="border border-slate-200 rounded-lg overflow-hidden">
{/* Cabeçalho com dias da semana */}
<div className="grid grid-cols-8 border-b border-slate-200 bg-slate-50">
<div className="p-3 border-r border-slate-200"></div>
{weekDays.map((day, idx) => (
<div
key={idx}
className={`p-3 text-center border-r border-slate-200 last:border-r-0 ${
isToday(day) ? 'bg-indigo-50' : ''
}`}
>
<div className="text-xs font-medium text-slate-500 mb-1">{dayNames[idx]}</div>
<div
className={`text-lg font-bold ${
isToday(day) ? 'text-indigo-700' : 'text-slate-900'
}`}
>
{day.getDate()}
</div>
</div>
))}
</div>
{/* Grade de horários */}
<div className="overflow-y-auto max-h-[600px]">
<div className="grid grid-cols-8">
{/* Coluna de horários */}
<div className="border-r border-slate-200 bg-slate-50">
{timeSlots.map((slot) => (
<div
key={slot}
className="h-16 border-b border-slate-100 flex items-start justify-end pr-2 pt-1"
>
<span className="text-xs text-slate-500">{slot}</span>
</div>
))}
</div>
{/* Colunas dos dias */}
{weekDays.map((day, dayIdx) => {
const currentTimePos = getCurrentTimePosition(day);
return (
<div key={dayIdx} className="border-r border-slate-200 last:border-r-0 relative">
{timeSlots.map((slot, slotIdx) => {
const slotAppointments = getAppointmentsAtSlot(day, slot);
const isCurrentTimeSlot = currentTimePos === slotIdx;
return (
<div
key={slot}
className="h-16 border-b border-slate-100 relative hover:bg-slate-50/50 transition-colors"
>
{/* Linha de hora atual */}
{isCurrentTimeSlot && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-indigo-500 z-10">
<div className="absolute -left-2 top-1/2 -translate-y-1/2 w-3 h-3 bg-indigo-500 rounded-full"></div>
</div>
)}
{/* Agendamentos */}
{slotAppointments.map((apt, aptIdx) => {
const service = shop.services.find((s) => s.id === apt.serviceId);
const barber = shop.barbers.find((b) => b.id === apt.barberId);
const statusColors = {
pendente: 'bg-amber-100 border-amber-300 text-amber-800',
confirmado: 'bg-green-100 border-green-300 text-green-800',
concluido: 'bg-blue-100 border-blue-300 text-blue-800',
cancelado: 'bg-red-100 border-red-300 text-red-800',
};
return (
<div
key={apt.id}
className={`absolute left-1 right-1 top-1 bottom-1 rounded border-l-2 p-1 text-xs overflow-hidden ${statusColors[apt.status]}`}
style={{ zIndex: 5 + aptIdx }}
>
<div className="font-semibold truncate">{service?.name || 'Serviço'}</div>
<div className="text-[10px] opacity-80 truncate">{barber?.name || 'Barbeiro'}</div>
<div className="text-[10px] opacity-70 mt-0.5">
{apt.date.split(' ')[1]?.substring(0, 5)}
</div>
</div>
);
})}
</div>
);
})}
</div>
);
})}
</div>
</div>
</div>
{/* Legenda */}
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-l-2 bg-amber-100 border-amber-300"></div>
<span>Pendente</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-l-2 bg-green-100 border-green-300"></div>
<span>Confirmado</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-l-2 bg-blue-100 border-blue-300"></div>
<span>Concluído</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-l-2 bg-red-100 border-red-300"></div>
<span>Cancelado</span>
</div>
</div>
</div>
);
};

View File

@@ -1,6 +1,6 @@
import clsx, { ClassValue } from 'classnames';
import clsx from 'classnames';
export const cn = (...inputs: ClassValue[]) => clsx(inputs);
export const cn = (...inputs: (string | undefined | null | boolean)[]) => clsx(inputs);

View File

@@ -1,4 +1,4 @@
export const currency = (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
export const currency = (v: number) => v.toLocaleString('pt-PT', { style: 'currency', currency: 'EUR' });

View File

@@ -75,13 +75,11 @@ export default function Booking() {
}
};
// Determinar qual etapa mostrar
const currentStep = !serviceId ? 1 : !barberId ? 2 : 3;
const steps = [
{ id: 1, label: 'Serviço', icon: Scissors, completed: !!serviceId, current: currentStep === 1 },
{ id: 2, label: 'Barbeiro', icon: User, completed: !!barberId, current: currentStep === 2 },
{ id: 3, label: 'Data & Hora', icon: Calendar, completed: !!date && !!slot, current: currentStep === 3 },
{ id: 1, label: 'Serviço', icon: Scissors, completed: !!serviceId },
{ id: 2, label: 'Barbeiro', icon: User, completed: !!barberId },
{ id: 3, label: 'Data', icon: Calendar, completed: !!date },
{ id: 4, label: 'Horário', icon: Clock, completed: !!slot },
];
return (
@@ -99,165 +97,119 @@ export default function Booking() {
<div
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 transition-all ${
step.completed
? 'bg-gradient-to-br from-indigo-500 to-blue-600 border-indigo-600 text-white shadow-md'
: step.current
? 'bg-gradient-to-br from-indigo-100 to-blue-100 border-indigo-500 text-indigo-600'
? 'bg-gradient-to-br from-amber-500 to-amber-600 border-amber-600 text-white shadow-md'
: 'bg-white border-slate-300 text-slate-400'
}`}
>
{step.completed ? <CheckCircle2 size={18} /> : <step.icon size={18} />}
</div>
<span className={`text-xs mt-2 font-medium ${
step.completed ? 'text-indigo-700' : step.current ? 'text-indigo-600 font-semibold' : 'text-slate-500'
}`}>
<span className={`text-xs mt-2 font-medium ${step.completed ? 'text-amber-700' : 'text-slate-500'}`}>
{step.label}
</span>
</div>
{idx < steps.length - 1 && (
<div className={`h-0.5 flex-1 mx-2 ${step.completed ? 'bg-indigo-500' : 'bg-slate-200'}`} />
<div className={`h-0.5 flex-1 mx-2 ${step.completed ? 'bg-amber-500' : 'bg-slate-200'}`} />
)}
</div>
))}
</div>
<Card className="p-6 space-y-6">
{/* Step 1: Service - Só aparece se não tiver serviço selecionado */}
{currentStep === 1 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Scissors size={18} className="text-indigo-600" />
<h3 className="text-base font-bold text-slate-900">1. Escolha o serviço</h3>
</div>
<div className="grid md:grid-cols-2 gap-3">
{shop.services.map((s) => (
<button
key={s.id}
onClick={() => setService(s.id)}
className={`p-4 rounded-xl border-2 text-left transition-all ${
serviceId === s.id
? 'border-indigo-500 bg-gradient-to-br from-indigo-50 to-blue-100/50 shadow-md scale-[1.02]'
: 'border-slate-200 hover:border-indigo-300 hover:bg-indigo-50/50'
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="font-bold text-slate-900">{s.name}</div>
<div className="text-sm font-bold text-indigo-600">{currency(s.price)}</div>
</div>
<div className="text-xs text-slate-500">Duração: {s.duration} min</div>
</button>
))}
</div>
{/* Step 1: Service */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Scissors size={18} className="text-amber-600" />
<h3 className="text-base font-bold text-slate-900">1. Escolha o serviço</h3>
</div>
)}
{/* Step 2: Barber - Só aparece se tiver serviço mas não barbeiro */}
{currentStep === 2 && (
<div className="space-y-3">
<div className="flex items-center gap-2 mb-4">
<div className="grid md:grid-cols-2 gap-3">
{shop.services.map((s) => (
<button
onClick={() => setService('')}
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium flex items-center gap-1"
key={s.id}
onClick={() => setService(s.id)}
className={`p-4 rounded-xl border-2 text-left transition-all ${
serviceId === s.id
? 'border-amber-500 bg-gradient-to-br from-amber-50 to-amber-100/50 shadow-md scale-[1.02]'
: 'border-slate-200 hover:border-amber-300 hover:bg-amber-50/50'
}`}
>
Voltar
<div className="flex items-center justify-between mb-1">
<div className="font-bold text-slate-900">{s.name}</div>
<div className="text-sm font-bold text-amber-600">{currency(s.price)}</div>
</div>
<div className="text-xs text-slate-500">Duração: {s.duration} min</div>
</button>
</div>
))}
</div>
</div>
{/* Step 2: Barber */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<User size={18} className="text-amber-600" />
<h3 className="text-base font-bold text-slate-900">2. Escolha o barbeiro</h3>
</div>
<div className="flex gap-2 flex-wrap">
{shop.barbers.map((b) => (
<button
key={b.id}
onClick={() => setBarber(b.id)}
className={`px-4 py-2.5 rounded-full border-2 text-sm font-medium transition-all ${
barberId === b.id
? 'border-amber-500 bg-gradient-to-r from-amber-500 to-amber-600 text-white shadow-md'
: 'border-slate-200 text-slate-700 hover:border-amber-300 hover:bg-amber-50'
}`}
>
{b.name}
{b.specialties.length > 0 && (
<span className="ml-2 text-xs opacity-80">· {b.specialties[0]}</span>
)}
</button>
))}
</div>
</div>
{/* Step 3 & 4: Date & Time */}
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-3">
<div className="flex items-center gap-2">
<User size={18} className="text-indigo-600" />
<h3 className="text-base font-bold text-slate-900">2. Escolha o barbeiro</h3>
<Calendar size={18} className="text-amber-600" />
<h3 className="text-base font-bold text-slate-900">3. Escolha a data</h3>
</div>
<Input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
/>
</div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Clock size={18} className="text-amber-600" />
<h3 className="text-base font-bold text-slate-900">4. Escolha o horário</h3>
</div>
{selectedService && (
<div className="mb-4 p-3 bg-indigo-50 rounded-lg border border-indigo-200">
<div className="text-sm text-slate-600">Serviço selecionado:</div>
<div className="font-semibold text-slate-900">{selectedService.name} - {currency(selectedService.price)}</div>
</div>
)}
<div className="flex gap-2 flex-wrap">
{shop.barbers.map((b) => (
<button
key={b.id}
onClick={() => setBarber(b.id)}
className={`px-4 py-2.5 rounded-full border-2 text-sm font-medium transition-all ${
barberId === b.id
? 'border-indigo-500 bg-gradient-to-r from-indigo-500 to-blue-600 text-white shadow-md'
: 'border-slate-200 text-slate-700 hover:border-indigo-300 hover:bg-indigo-50'
}`}
>
{b.name}
{b.specialties.length > 0 && (
<span className="ml-2 text-xs opacity-80">· {b.specialties[0]}</span>
)}
</button>
))}
{!barberId || !date ? (
<p className="text-sm text-slate-500 py-2">Escolha primeiro o barbeiro e a data.</p>
) : availableSlots.length > 0 ? (
availableSlots.map((h) => (
<button
key={h}
onClick={() => setSlot(h)}
className={`px-4 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
slot === h
? 'border-amber-500 bg-gradient-to-r from-amber-500 to-amber-600 text-white shadow-md'
: 'border-slate-200 text-slate-700 hover:border-amber-300 hover:bg-amber-50'
}`}
>
{h}
</button>
))
) : (
<p className="text-sm text-amber-600 py-2 font-medium">Nenhum horário disponível para esta data.</p>
)}
</div>
</div>
)}
{/* Step 3: Date & Time - Só aparece se tiver serviço e barbeiro */}
{currentStep === 3 && (
<div className="space-y-6">
<div className="flex items-center gap-2 mb-4">
<button
onClick={() => setBarber('')}
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium flex items-center gap-1"
>
Voltar
</button>
</div>
<div className="flex items-center gap-2 mb-4">
<Calendar size={18} className="text-indigo-600" />
<h3 className="text-base font-bold text-slate-900">3. Escolha a data e horário</h3>
</div>
{selectedService && selectedBarber && (
<div className="mb-4 p-3 bg-indigo-50 rounded-lg border border-indigo-200 space-y-1">
<div className="text-sm text-slate-600">Serviço: <span className="font-semibold text-slate-900">{selectedService.name}</span></div>
<div className="text-sm text-slate-600">Barbeiro: <span className="font-semibold text-slate-900">{selectedBarber.name}</span></div>
</div>
)}
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Calendar size={18} className="text-indigo-600" />
<h4 className="text-sm font-bold text-slate-900">Data</h4>
</div>
<Input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
/>
</div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Clock size={18} className="text-indigo-600" />
<h4 className="text-sm font-bold text-slate-900">Horário</h4>
</div>
<div className="flex gap-2 flex-wrap">
{!date ? (
<p className="text-sm text-slate-500 py-2">Escolha primeiro a data.</p>
) : availableSlots.length > 0 ? (
availableSlots.map((h) => (
<button
key={h}
onClick={() => setSlot(h)}
className={`px-4 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
slot === h
? 'border-indigo-500 bg-gradient-to-r from-indigo-500 to-blue-600 text-white shadow-md'
: 'border-slate-200 text-slate-700 hover:border-indigo-300 hover:bg-indigo-50'
}`}
>
{h}
</button>
))
) : (
<p className="text-sm text-indigo-600 py-2 font-medium">Nenhum horário disponível para esta data.</p>
)}
</div>
</div>
</div>
</div>
)}
</div>
{/* Summary */}
{canSubmit && selectedService && (
@@ -280,7 +232,7 @@ export default function Booking() {
</div>
<div className="flex justify-between pt-2 border-t border-slate-200">
<span className="font-bold text-slate-900">Total:</span>
<span className="font-bold text-lg text-indigo-600">{currency(selectedService.price)}</span>
<span className="font-bold text-lg text-amber-600">{currency(selectedService.price)}</span>
</div>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import { currency } from '../lib/format';
import { useApp } from '../context/AppContext';
import { Product } from '../types';
import { BarChart, Bar, CartesianGrid, ResponsiveContainer, Tooltip, XAxis } from 'recharts';
import { CalendarWeekView } from '../components/CalendarWeekView';
import {
BarChart3,
Calendar,
@@ -22,6 +23,18 @@ import {
Minus,
Plus as PlusIcon,
History,
ChevronLeft,
ChevronRight,
List,
ZoomIn,
ZoomOut,
RefreshCw,
Search,
Printer,
ChevronDown,
UserPlus,
Globe,
TrendingUp,
} from 'lucide-react';
const periods: Record<string, (date: Date) => boolean> = {
@@ -48,6 +61,7 @@ type TabId = 'overview' | 'appointments' | 'history' | 'orders' | 'services' | '
export default function Dashboard() {
const {
user,
users,
shops,
appointments,
orders,
@@ -65,6 +79,11 @@ export default function Dashboard() {
const [activeTab, setActiveTab] = useState<TabId>('overview');
const [period, setPeriod] = useState<keyof typeof periods>('semana');
const [appointmentView, setAppointmentView] = useState<'list' | 'calendar'>('list');
const [currentWeek, setCurrentWeek] = useState(new Date());
const [searchQuery, setSearchQuery] = useState('');
const [includeCancelled, setIncludeCancelled] = useState(false);
const [selectedDate, setSelectedDate] = useState(new Date());
// Form states
const [svcName, setSvcName] = useState('');
@@ -87,6 +106,58 @@ export default function Dashboard() {
const shopAppointments = allShopAppointments.filter((a) => a.status !== 'concluido');
// Agendamentos concluídos (histórico)
const completedAppointments = allShopAppointments.filter((a) => a.status === 'concluido');
// Estatísticas para lista de marcações
const todayAppointments = appointments.filter((a) => {
if (a.shopId !== shop.id) return false;
const aptDate = new Date(a.date.replace(' ', 'T'));
const today = new Date();
return (
aptDate.getDate() === today.getDate() &&
aptDate.getMonth() === today.getMonth() &&
aptDate.getFullYear() === today.getFullYear()
);
});
const totalBookingsToday = todayAppointments.filter((a) => includeCancelled || a.status !== 'cancelado').length;
const newClientsToday = useMemo(() => {
const clientIds = new Set(todayAppointments.map((a) => a.customerId));
return clientIds.size;
}, [todayAppointments]);
const onlineBookingsToday = todayAppointments.filter((a) => a.status !== 'cancelado').length;
const occupancyRate = useMemo(() => {
// Calcular ocupação baseada em slots disponíveis (8h-18h = 20 slots de 30min)
const totalSlots = 20;
const bookedSlots = todayAppointments.filter((a) => a.status !== 'cancelado').length;
return Math.round((bookedSlots / totalSlots) * 100);
}, [todayAppointments]);
// Filtrar agendamentos para lista
const filteredAppointments = useMemo(() => {
let filtered = shopAppointments;
if (!includeCancelled) {
filtered = filtered.filter((a) => a.status !== 'cancelado');
}
if (searchQuery) {
filtered = filtered.filter((a) => {
const service = shop.services.find((s) => s.id === a.serviceId);
const barber = shop.barbers.find((b) => b.id === a.barberId);
const customer = users.find((u) => u.id === a.customerId);
const searchLower = searchQuery.toLowerCase();
return (
service?.name.toLowerCase().includes(searchLower) ||
barber?.name.toLowerCase().includes(searchLower) ||
customer?.name.toLowerCase().includes(searchLower) ||
customer?.email.toLowerCase().includes(searchLower) ||
a.date.toLowerCase().includes(searchLower)
);
});
}
return filtered;
}, [shopAppointments, includeCancelled, searchQuery, shop.services, shop.barbers, users]);
// Pedidos apenas com produtos (não serviços)
const shopOrders = orders.filter(
(o) => o.shopId === shop.id && periodMatch(new Date(o.createdAt)) && o.items.some((item) => item.type === 'product')
@@ -331,50 +402,258 @@ export default function Dashboard() {
)}
{activeTab === 'appointments' && (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-slate-900">Agendamentos</h2>
<div className="space-y-4">
{/* View Toggle Buttons */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => setAppointmentView('list')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-all ${
appointmentView === 'list'
? 'border-indigo-500 bg-indigo-50 text-indigo-700 font-semibold'
: 'border-slate-200 text-slate-700 hover:border-indigo-300 hover:bg-indigo-50/50'
}`}
>
<List size={18} />
<span>Lista de marcações</span>
</button>
<button
onClick={() => setAppointmentView('calendar')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-all ${
appointmentView === 'calendar'
? 'border-indigo-500 bg-indigo-50 text-indigo-700 font-semibold'
: 'border-slate-200 text-slate-700 hover:border-indigo-300 hover:bg-indigo-50/50'
}`}
>
<Calendar size={18} />
<span>Calendário</span>
</button>
</div>
<Badge color="slate" variant="soft">{shopAppointments.length} no período</Badge>
</div>
<div className="space-y-3">
{shopAppointments.length > 0 ? (
shopAppointments.map((a) => {
const svc = shop.services.find((s) => s.id === a.serviceId);
const barber = shop.barbers.find((b) => b.id === a.barberId);
return (
<div key={a.id} className="flex items-center justify-between p-4 border border-slate-200 rounded-lg hover:border-amber-300 transition-colors">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<p className="font-bold text-slate-900">{svc?.name ?? 'Serviço'}</p>
<Badge color={a.status === 'pendente' ? 'amber' : a.status === 'confirmado' ? 'green' : a.status === 'concluido' ? 'green' : 'red'}>
{a.status}
</Badge>
</div>
<p className="text-sm text-slate-600">{barber?.name ?? 'Barbeiro'} · {a.date}</p>
<p className="text-xs text-slate-500 mt-1">{currency(a.total)}</p>
{/* List View */}
{appointmentView === 'list' && (
<div className="space-y-6">
{/* Cards de Estatísticas */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="p-5">
<div className="flex items-center justify-between mb-2">
<div className="p-2 bg-indigo-500 rounded-lg text-white">
<Calendar size={20} />
</div>
<select
className="text-sm border border-slate-300 rounded-lg px-3 py-2 focus:border-amber-500 focus:ring-2 focus:ring-amber-500/30"
value={a.status}
onChange={(e) => updateAppointmentStatus(a.id, e.target.value as any)}
>
{['pendente', 'confirmado', 'concluido', 'cancelado'].map((s) => (
<option key={s} value={s}>
{s === 'pendente' ? 'Pendente' : s === 'confirmado' ? 'Confirmado' : s === 'concluido' ? 'Concluído' : 'Cancelado'}
</option>
))}
</select>
<span className="text-xs text-slate-500">0%</span>
</div>
);
})
) : (
<div className="text-center py-12">
<Calendar size={48} className="mx-auto text-slate-300 mb-3" />
<p className="text-slate-600 font-medium">Nenhum agendamento no período</p>
<p className="text-sm text-slate-600 mb-1">Total de marcações</p>
<p className="text-2xl font-bold text-slate-900">{totalBookingsToday}</p>
</Card>
<Card className="p-5">
<div className="flex items-center justify-between mb-2">
<div className="p-2 bg-green-500 rounded-lg text-white">
<UserPlus size={20} />
</div>
<span className="text-xs text-slate-500">0%</span>
</div>
<p className="text-sm text-slate-600 mb-1">Novos clientes</p>
<p className="text-2xl font-bold text-slate-900">{newClientsToday}</p>
</Card>
<Card className="p-5">
<div className="flex items-center justify-between mb-2">
<div className="p-2 bg-blue-500 rounded-lg text-white">
<Globe size={20} />
</div>
<span className="text-xs text-slate-500">0%</span>
</div>
<p className="text-sm text-slate-600 mb-1">Marcações online</p>
<p className="text-2xl font-bold text-slate-900">{onlineBookingsToday}</p>
</Card>
<Card className="p-5">
<div className="flex items-center justify-between mb-2">
<div className="p-2 bg-purple-500 rounded-lg text-white">
<TrendingUp size={20} />
</div>
<span className="text-xs text-slate-500">0%</span>
</div>
<p className="text-sm text-slate-600 mb-1">Ocupação</p>
<p className="text-2xl font-bold text-slate-900">{occupancyRate}%</p>
</Card>
</div>
)}
</div>
</Card>
{/* Barra de Pesquisa e Filtros */}
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
<div className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="Pesquisar por cliente, serviço ou barbeiro..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-lg border border-slate-300 bg-white px-10 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 transition-all"
/>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setIncludeCancelled(!includeCancelled)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-all ${
includeCancelled
? 'border-indigo-500 bg-indigo-50 text-indigo-700'
: 'border-slate-200 text-slate-700 hover:border-indigo-300'
}`}
>
<span>Incluir cancelamentos</span>
<ChevronDown size={16} />
</button>
<Button variant="outline" size="sm">
<Printer size={18} className="mr-2" />
Imprimir
</Button>
</div>
</div>
{/* Navegação de Data */}
<div className="flex items-center gap-4">
<Button variant="outline" size="sm" onClick={() => setSelectedDate(new Date())}>
Hoje
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => {
const newDate = new Date(selectedDate);
newDate.setDate(newDate.getDate() - 1);
setSelectedDate(newDate);
}}>
<ChevronLeft size={18} />
</Button>
<div className="text-sm font-medium text-slate-700">
{selectedDate.toLocaleDateString('pt-PT', {
weekday: 'long',
day: 'numeric',
month: 'numeric',
year: 'numeric'
})}
</div>
<Button variant="outline" size="sm" onClick={() => {
const newDate = new Date(selectedDate);
newDate.setDate(newDate.getDate() + 1);
setSelectedDate(newDate);
}}>
<ChevronRight size={18} />
</Button>
</div>
</div>
{/* Lista de Agendamentos */}
<Card className="p-6">
{filteredAppointments.length > 0 ? (
<div className="space-y-3">
{filteredAppointments.map((a) => {
const svc = shop.services.find((s) => s.id === a.serviceId);
const barber = shop.barbers.find((b) => b.id === a.barberId);
const customer = users.find((u) => u.id === a.customerId);
const aptDate = new Date(a.date.replace(' ', 'T'));
const timeStr = aptDate.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' });
const dateStr = aptDate.toLocaleDateString('pt-PT', { day: 'numeric', month: 'long', year: 'numeric' });
return (
<div
key={a.id}
className="flex items-center justify-between p-4 border border-slate-200 rounded-lg hover:border-indigo-300 hover:shadow-sm transition-all"
>
<div className="flex-1 grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-slate-500 mb-1">Cliente</p>
<p className="font-semibold text-slate-900">{customer?.name || 'Cliente'}</p>
<p className="text-xs text-slate-500">{customer?.email || ''}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">Serviço</p>
<p className="font-semibold text-slate-900">{svc?.name ?? 'Serviço'}</p>
<p className="text-xs text-slate-500">{barber?.name ?? 'Barbeiro'}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">Data e Hora</p>
<p className="font-semibold text-slate-900">{dateStr}</p>
<p className="text-xs text-slate-500">{timeStr}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">Status</p>
<div className="flex items-center gap-2">
<Badge
color={
a.status === 'pendente'
? 'amber'
: a.status === 'confirmado'
? 'green'
: a.status === 'concluido'
? 'green'
: 'red'
}
>
{a.status === 'pendente'
? 'Pendente'
: a.status === 'confirmado'
? 'Confirmado'
: a.status === 'concluido'
? 'Concluído'
: 'Cancelado'}
</Badge>
<p className="text-sm font-semibold text-slate-900">{currency(a.total)}</p>
</div>
</div>
</div>
<div className="ml-4">
<select
className="text-sm border border-slate-300 rounded-lg px-3 py-2 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30"
value={a.status}
onChange={(e) => updateAppointmentStatus(a.id, e.target.value as any)}
>
{['pendente', 'confirmado', 'concluido', 'cancelado'].map((s) => (
<option key={s} value={s}>
{s === 'pendente'
? 'Pendente'
: s === 'confirmado'
? 'Confirmado'
: s === 'concluido'
? 'Concluído'
: 'Cancelado'}
</option>
))}
</select>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-16">
<Calendar size={64} className="mx-auto text-slate-300 mb-4" />
<p className="text-lg font-semibold text-slate-900 mb-2">Sem reservas</p>
<p className="text-sm text-slate-600 max-w-md mx-auto">
Ambas as suas reservas online e manuais aparecerão aqui
</p>
</div>
)}
</Card>
</div>
)}
{/* Calendar View */}
{appointmentView === 'calendar' && (
<Card className="p-6">
<CalendarWeekView
week={currentWeek}
appointments={shopAppointments}
shop={shop}
onWeekChange={setCurrentWeek}
onStatusChange={updateAppointmentStatus}
/>
</Card>
)}
</div>
)}
{activeTab === 'history' && (