Add project report and implement calendar week view component
This commit is contained in:
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