Add project report and implement calendar week view component
This commit is contained in:
423
RELATORIO_PROJETO.md
Normal file
423
RELATORIO_PROJETO.md
Normal 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
|
||||
269
web/src/components/CalendarWeekView.tsx
Normal file
269
web/src/components/CalendarWeekView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
Reference in New Issue
Block a user