feat: Fase 1 - sincronização completa DB-first (Settings, Dashboard, Profile, Booking)
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m38s
Details
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m38s
Details
This commit is contained in:
parent
d2f0670ce7
commit
959c6f6039
|
|
@ -1,29 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { useState, useEffect, use } from 'react';
|
import { useState, useEffect, use } from 'react';
|
||||||
import { Calendar, Clock, User, Phone, Mail, ChevronLeft, ChevronRight, Check, Scissors, MapPin, Star } from 'lucide-react';
|
import { Calendar, Clock, User, Phone, Mail, ChevronLeft, ChevronRight, Check, Scissors, MapPin, Star, Loader } from 'lucide-react';
|
||||||
|
|
||||||
const establishment = {
|
|
||||||
name: 'Barbearia Premium',
|
|
||||||
type: 'barbearia',
|
|
||||||
description: 'A melhor barbearia da cidade. Cortes modernos, barbas impecáveis e ambiente premium.',
|
|
||||||
phone: '(11) 99999-1234',
|
|
||||||
address: 'Rua das Flores, 123 - Centro, São Paulo - SP',
|
|
||||||
rating: 4.9,
|
|
||||||
reviews: 127,
|
|
||||||
};
|
|
||||||
|
|
||||||
const services = [
|
|
||||||
{ id: '1', category: 'Cabelo', name: 'Corte Masculino', desc: 'Corte moderno com lavagem e finalização', duration: 40, price: 55 },
|
|
||||||
{ id: '2', category: 'Cabelo', name: 'Corte Infantil', desc: 'Corte para crianças até 12 anos', duration: 30, price: 40 },
|
|
||||||
{ id: '3', category: 'Barba', name: 'Barba Completa', desc: 'Aparar, modelar e hidratação', duration: 30, price: 40 },
|
|
||||||
{ id: '4', category: 'Barba', name: 'Design de Sobrancelha', desc: 'Alinhamento com navalha', duration: 15, price: 25 },
|
|
||||||
{ id: '5', category: 'Combos', name: 'Combo Corte + Barba', desc: 'Corte masculino + barba completa', duration: 60, price: 85 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const professionals = [
|
|
||||||
{ id: '1', name: 'Carlos Silva', role: 'Barbeiro Senior', rating: 4.8 },
|
|
||||||
{ id: '2', name: 'Rafael Santos', role: 'Barbeiro', rating: 4.6 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function PublicBookingPage({ params }: { params: Promise<{ slug: string }> }) {
|
export default function PublicBookingPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
const { slug } = use(params);
|
const { slug } = use(params);
|
||||||
|
|
@ -38,41 +15,91 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
||||||
const [clientEmail, setClientEmail] = useState('');
|
const [clientEmail, setClientEmail] = useState('');
|
||||||
const [booked, setBooked] = useState(false);
|
const [booked, setBooked] = useState(false);
|
||||||
const [slots, setSlots] = useState<string[]>([]);
|
const [slots, setSlots] = useState<string[]>([]);
|
||||||
|
const [loadingDb, setLoadingDb] = useState(true);
|
||||||
|
|
||||||
|
const [dbServices, setDbServices] = useState<any[]>([]);
|
||||||
|
const [dbProfessionals, setDbProfessionals] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// Dados do tenant vindos do banco
|
||||||
|
const [establishment, setEstablishment] = useState({
|
||||||
|
name: '', type: 'barbearia', description: '', phone: '',
|
||||||
|
address: '', rating: 5.0, reviews: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Algoritmo dinâmico de fatiamento de grade
|
||||||
|
const generateSlots = (startTimeStr: string, endTimeStr: string, intervalMin: number) => {
|
||||||
|
const generated: string[] = [];
|
||||||
|
const [sh, sm] = startTimeStr.split(':').map(Number);
|
||||||
|
const [eh, em] = endTimeStr.split(':').map(Number);
|
||||||
|
|
||||||
|
let currentMinutes = sh * 60 + sm;
|
||||||
|
const endMinutes = eh * 60 + em;
|
||||||
|
|
||||||
|
while (currentMinutes < endMinutes) {
|
||||||
|
const h = Math.floor(currentMinutes / 60);
|
||||||
|
const m = currentMinutes % 60;
|
||||||
|
const timeStr = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
generated.push(timeStr);
|
||||||
|
currentMinutes += intervalMin;
|
||||||
|
}
|
||||||
|
return generated;
|
||||||
|
};
|
||||||
|
|
||||||
// Carrega e gera os slots dinâmicos do barbeiro
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getCookie = (name: string): string => {
|
const fetchData = async () => {
|
||||||
if (typeof document === 'undefined') return '';
|
try {
|
||||||
const value = `; ${document.cookie}`;
|
const [profileRes, agendaRes, servicesRes] = await Promise.all([
|
||||||
const parts = value.split(`; ${name}=`);
|
fetch(`/api/tenant/profile?slug=${slug}`),
|
||||||
if (parts.length === 2) return parts.pop()!.split(';').shift() || '';
|
fetch(`/api/tenant/agenda?slug=${slug}`),
|
||||||
return '';
|
fetch(`/api/tenant/services?slug=${slug}`)
|
||||||
};
|
]);
|
||||||
|
|
||||||
|
// Carregar perfil do tenant
|
||||||
|
if (profileRes.ok) {
|
||||||
|
const profile = await profileRes.json();
|
||||||
|
const fullAddress = [profile.address, profile.city, profile.state].filter(Boolean).join(', ');
|
||||||
|
setEstablishment({
|
||||||
|
name: profile.name || slug,
|
||||||
|
type: profile.businessType || 'barbearia',
|
||||||
|
description: profile.description || '',
|
||||||
|
phone: profile.phone || '',
|
||||||
|
address: fullAddress || '',
|
||||||
|
rating: 5.0,
|
||||||
|
reviews: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gerar slots com horários do tenant
|
||||||
|
const startTime = profile.startTime || '09:00';
|
||||||
|
const endTime = profile.endTime || '19:00';
|
||||||
|
const interval = profile.interval || 30;
|
||||||
|
setSlots(generateSlots(startTime, endTime, interval));
|
||||||
|
|
||||||
|
// Aplicar cor do tema
|
||||||
|
if (profile.themeColor) {
|
||||||
|
document.documentElement.style.setProperty('--accent', profile.themeColor);
|
||||||
|
document.documentElement.style.setProperty('--accent-glow', `${profile.themeColor}26`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback
|
||||||
|
setSlots(generateSlots('09:00', '19:00', 30));
|
||||||
|
}
|
||||||
|
|
||||||
const start = getCookie(`agendapro_start_time_${slug}`) || localStorage.getItem(`agendapro_start_time_${slug}`) || '09:00';
|
if (agendaRes.ok) {
|
||||||
const end = getCookie(`agendapro_end_time_${slug}`) || localStorage.getItem(`agendapro_end_time_${slug}`) || '19:00';
|
const data = await agendaRes.json();
|
||||||
const interval = Number(getCookie(`agendapro_interval_${slug}`) || localStorage.getItem(`agendapro_interval_${slug}`) || '30');
|
setDbProfessionals(data.professionals || []);
|
||||||
|
}
|
||||||
// Algoritmo dinâmico de fatiamento de grade
|
if (servicesRes.ok) {
|
||||||
const generateSlots = (startTimeStr: string, endTimeStr: string, intervalMin: number) => {
|
const data = await servicesRes.json();
|
||||||
const generated = [];
|
setDbServices(data || []);
|
||||||
let [sh, sm] = startTimeStr.split(':').map(Number);
|
}
|
||||||
const [eh, em] = endTimeStr.split(':').map(Number);
|
} catch (err) {
|
||||||
|
console.error('Erro ao buscar dados do banco:', err);
|
||||||
let currentMinutes = sh * 60 + sm;
|
setSlots(generateSlots('09:00', '19:00', 30));
|
||||||
const endMinutes = eh * 60 + em;
|
} finally {
|
||||||
|
setLoadingDb(false);
|
||||||
while (currentMinutes < endMinutes) {
|
|
||||||
const h = Math.floor(currentMinutes / 60);
|
|
||||||
const m = currentMinutes % 60;
|
|
||||||
const timeStr = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
|
||||||
generated.push(timeStr);
|
|
||||||
currentMinutes += intervalMin;
|
|
||||||
}
|
}
|
||||||
return generated;
|
|
||||||
};
|
};
|
||||||
|
fetchData();
|
||||||
setSlots(generateSlots(start, end, interval));
|
|
||||||
}, [slug]);
|
}, [slug]);
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
|
|
@ -82,8 +109,8 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
||||||
return d;
|
return d;
|
||||||
});
|
});
|
||||||
|
|
||||||
const svc = services.find(s => s.id === selectedService);
|
const svc = dbServices.find(s => s.id === selectedService);
|
||||||
const prof = professionals.find(p => p.id === selectedProfessional);
|
const prof = dbProfessionals.find(p => p.id === selectedProfessional);
|
||||||
|
|
||||||
const canNext = () => {
|
const canNext = () => {
|
||||||
if (step === 1) return !!selectedService;
|
if (step === 1) return !!selectedService;
|
||||||
|
|
@ -93,44 +120,37 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBook = () => {
|
const handleBook = async () => {
|
||||||
// 1. Monta o objeto de agendamento no formato esperado pela Agenda
|
|
||||||
const [startHour, startMin] = selectedTime ? selectedTime.split(':').map(Number) : [9, 0];
|
const [startHour, startMin] = selectedTime ? selectedTime.split(':').map(Number) : [9, 0];
|
||||||
const newAppointment = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
professionalId: selectedProfessional || '1',
|
|
||||||
client: clientName,
|
|
||||||
phone: clientPhone,
|
|
||||||
email: clientEmail,
|
|
||||||
service: svc?.name || 'Serviço',
|
|
||||||
price: svc?.price || 0,
|
|
||||||
duration: svc?.duration || 30,
|
|
||||||
startHour,
|
|
||||||
startMin,
|
|
||||||
status: 'pending', // Inicia pendente de aprovação!
|
|
||||||
date: selectedDate, // Data do agendamento (ex: "2026-05-31")
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. Salva no localStorage com isolamento por slug (Tenant Isolation)
|
try {
|
||||||
const key = `agendapro_appointments_${slug}`;
|
const res = await fetch('/api/tenant/agenda', {
|
||||||
const existing = localStorage.getItem(key);
|
method: 'POST',
|
||||||
let currentList = [];
|
headers: { 'Content-Type': 'application/json' },
|
||||||
if (existing) {
|
body: JSON.stringify({
|
||||||
currentList = JSON.parse(existing);
|
slug,
|
||||||
} else {
|
professionalId: selectedProfessional || '1',
|
||||||
// Se não existia e for barbearia premium demo, pega os mockAppointments padrão
|
client: clientName,
|
||||||
currentList = slug === 'barbearia-premium' ? [
|
phone: clientPhone,
|
||||||
{ id: '1', professionalId: '1', client: 'João Mendes', service: 'Corte Masculino', startHour: 10, startMin: 0, duration: 40, status: 'confirmed', price: 55, date: new Date().toISOString().split('T')[0] },
|
email: clientEmail,
|
||||||
{ id: '2', professionalId: '1', client: 'Pedro Oliveira', service: 'Combo Corte + Barba', startHour: 11, startMin: 0, duration: 60, status: 'confirmed', price: 85, date: new Date().toISOString().split('T')[0] },
|
service: svc?.name || 'Serviço',
|
||||||
{ id: '3', professionalId: '2', client: 'Lucas Ferreira', service: 'Barba Completa', startHour: 14, startMin: 0, duration: 30, status: 'pending', price: 40, date: new Date().toISOString().split('T')[0] },
|
price: svc?.price || 0,
|
||||||
{ id: '4', professionalId: '2', client: 'Gabriel Souza', service: 'Corte Masculino', startHour: 10, startMin: 0, duration: 40, status: 'completed', price: 55, date: new Date().toISOString().split('T')[0] },
|
duration: svc?.duration || 30,
|
||||||
{ id: '5', professionalId: '1', client: 'Mateus Costa', service: 'Design de Sobrancelha', startHour: 15, startMin: 30, duration: 15, status: 'confirmed', price: 25, date: new Date().toISOString().split('T')[0] }
|
startHour,
|
||||||
] : [];
|
startMin,
|
||||||
|
status: 'pending',
|
||||||
|
date: selectedDate,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setBooked(true);
|
||||||
|
} else {
|
||||||
|
console.error('Falha ao agendar no banco.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
currentList.push(newAppointment);
|
|
||||||
localStorage.setItem(key, JSON.stringify(currentList));
|
|
||||||
setBooked(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (booked) {
|
if (booked) {
|
||||||
|
|
@ -223,7 +243,7 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
||||||
<div className="slide-up">
|
<div className="slide-up">
|
||||||
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '20px' }}>Escolha o serviço</h2>
|
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '20px' }}>Escolha o serviço</h2>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
{services.map(s => (
|
{dbServices.map(s => (
|
||||||
<div key={s.id} onClick={() => setSelectedService(s.id)} style={{
|
<div key={s.id} onClick={() => setSelectedService(s.id)} style={{
|
||||||
padding: '18px 20px', borderRadius: 'var(--radius-md)', cursor: 'pointer',
|
padding: '18px 20px', borderRadius: 'var(--radius-md)', cursor: 'pointer',
|
||||||
background: selectedService === s.id ? 'var(--accent-glow)' : 'var(--bg-card)',
|
background: selectedService === s.id ? 'var(--accent-glow)' : 'var(--bg-card)',
|
||||||
|
|
@ -232,13 +252,13 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
||||||
}}>
|
}}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 700, fontSize: '15px', marginBottom: '4px' }}>{s.name}</div>
|
<div style={{ fontWeight: 700, fontSize: '15px', marginBottom: '4px' }}>{s.name}</div>
|
||||||
<div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>{s.desc}</div>
|
<div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>{s.desc || s.description || 'Serviço profissional'}</div>
|
||||||
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
<Clock size={12} /> {s.duration} min
|
<Clock size={12} /> {s.duration} min
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<div style={{ fontSize: '20px', fontWeight: 800, color: 'var(--accent)' }}>R$ {s.price.toFixed(2)}</div>
|
<div style={{ fontSize: '20px', fontWeight: 800, color: 'var(--accent)' }}>R$ {Number(s.price).toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -251,20 +271,20 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
||||||
<div className="slide-up">
|
<div className="slide-up">
|
||||||
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '20px' }}>Escolha o profissional</h2>
|
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '20px' }}>Escolha o profissional</h2>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
{professionals.map(p => (
|
{dbProfessionals.map(p => (
|
||||||
<div key={p.id} onClick={() => setSelectedProfessional(p.id)} style={{
|
<div key={p.id} onClick={() => setSelectedProfessional(p.id)} style={{
|
||||||
padding: '20px', borderRadius: 'var(--radius-md)', cursor: 'pointer',
|
padding: '20px', borderRadius: 'var(--radius-md)', cursor: 'pointer',
|
||||||
background: selectedProfessional === p.id ? 'var(--accent-glow)' : 'var(--bg-card)',
|
background: selectedProfessional === p.id ? 'var(--accent-glow)' : 'var(--bg-card)',
|
||||||
border: `2px solid ${selectedProfessional === p.id ? 'var(--accent)' : 'var(--border-color)'}`,
|
border: `2px solid ${selectedProfessional === p.id ? 'var(--accent)' : 'var(--border-color)'}`,
|
||||||
transition: 'all 0.2s', display: 'flex', alignItems: 'center', gap: '16px'
|
transition: 'all 0.2s', display: 'flex', alignItems: 'center', gap: '16px'
|
||||||
}}>
|
}}>
|
||||||
<div className="avatar avatar-lg">{p.name.split(' ').map(n => n[0]).join('')}</div>
|
<div className="avatar avatar-lg">{p.name.split(' ').map((n: string) => n[0]).join('').substring(0, 2).toUpperCase()}</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontWeight: 700, fontSize: '15px' }}>{p.name}</div>
|
<div style={{ fontWeight: 700, fontSize: '15px' }}>{p.name}</div>
|
||||||
<div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>{p.role}</div>
|
<div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>{p.role || 'Barbeiro/Profissional'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', color: 'var(--warning)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', color: 'var(--warning)' }}>
|
||||||
<Star size={14} fill="var(--warning)" /> <span style={{ fontWeight: 700 }}>{p.rating}</span>
|
<Star size={14} fill="var(--warning)" /> <span style={{ fontWeight: 700 }}>{p.rating || '5.0'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { query } from '@/lib/db';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const { token, currentPassword, newPassword } = await req.json();
|
||||||
|
|
||||||
|
if (!token || !currentPassword || !newPassword) {
|
||||||
|
return NextResponse.json({ error: 'Campos obrigatórios: token, senha atual e nova senha' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode token to get userId
|
||||||
|
const secret = process.env.JWT_SECRET || 'fallback_secret_for_development';
|
||||||
|
let decoded: any;
|
||||||
|
try {
|
||||||
|
decoded = jwt.verify(token, secret);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: 'Token inválido ou expirado. Faça login novamente.' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = decoded.userId;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Token inválido' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
const userRes = await query('SELECT id, password_hash FROM users WHERE id = $1', [userId]);
|
||||||
|
if (userRes.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Usuário não encontrado' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userRes.rows[0];
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const isValid = await bcrypt.compare(currentPassword, user.password_hash);
|
||||||
|
if (!isValid) {
|
||||||
|
return NextResponse.json({ error: 'Senha atual incorreta' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password and update
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
const newHash = await bcrypt.hash(newPassword, salt);
|
||||||
|
|
||||||
|
await query('UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2', [newHash, userId]);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Senha alterada com sucesso' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Change password error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { query } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const slug = searchParams.get('slug');
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
return NextResponse.json({ error: 'Missing tenant slug' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenantRes = await query('SELECT id FROM tenants WHERE slug = $1', [slug]);
|
||||||
|
if (tenantRes.rows.length === 0) return NextResponse.json({ error: 'Tenant not found' }, { status: 404 });
|
||||||
|
const tenantId = tenantRes.rows[0].id;
|
||||||
|
|
||||||
|
// 1. Agendamentos de hoje
|
||||||
|
const todayApptsRes = await query(`
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
TO_CHAR(a.start_time, 'HH24:MI') as time,
|
||||||
|
COALESCE(c.name, a.client_name, 'Cliente Avulso') as client,
|
||||||
|
COALESCE(s.name, 'Serviço') as service,
|
||||||
|
COALESCE(u.name, 'Profissional') as professional,
|
||||||
|
a.status,
|
||||||
|
a.price
|
||||||
|
FROM appointments a
|
||||||
|
LEFT JOIN clients c ON a.client_id = c.id
|
||||||
|
LEFT JOIN services s ON a.service_id = s.id
|
||||||
|
LEFT JOIN users u ON a.professional_id = u.id
|
||||||
|
WHERE a.tenant_id = $1 AND a.date = CURRENT_DATE
|
||||||
|
ORDER BY a.start_time ASC
|
||||||
|
`, [tenantId]);
|
||||||
|
|
||||||
|
// 2. KPIs - Agendamentos do dia
|
||||||
|
const kpiTodayRes = await query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_today,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'completed') as completed_today,
|
||||||
|
COALESCE(SUM(price) FILTER (WHERE status IN ('completed', 'confirmed', 'in_progress')), 0) as revenue_today
|
||||||
|
FROM appointments
|
||||||
|
WHERE tenant_id = $1 AND date = CURRENT_DATE
|
||||||
|
`, [tenantId]);
|
||||||
|
|
||||||
|
// 3. Clientes ativos (total)
|
||||||
|
const clientsRes = await query(`
|
||||||
|
SELECT COUNT(*) as total_clients FROM clients WHERE tenant_id = $1 AND is_active = true
|
||||||
|
`, [tenantId]);
|
||||||
|
|
||||||
|
// 4. Agendamentos semana anterior (para comparação)
|
||||||
|
const lastWeekRes = await query(`
|
||||||
|
SELECT COUNT(*) as total_last_week
|
||||||
|
FROM appointments
|
||||||
|
WHERE tenant_id = $1 AND date BETWEEN CURRENT_DATE - INTERVAL '14 days' AND CURRENT_DATE - INTERVAL '7 days'
|
||||||
|
`, [tenantId]);
|
||||||
|
|
||||||
|
const thisWeekRes = await query(`
|
||||||
|
SELECT COUNT(*) as total_this_week
|
||||||
|
FROM appointments
|
||||||
|
WHERE tenant_id = $1 AND date BETWEEN CURRENT_DATE - INTERVAL '7 days' AND CURRENT_DATE
|
||||||
|
`, [tenantId]);
|
||||||
|
|
||||||
|
// 5. Desempenho semanal (últimos 7 dias - Seg a Sáb)
|
||||||
|
const weeklyRes = await query(`
|
||||||
|
SELECT
|
||||||
|
EXTRACT(DOW FROM date) as dow,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM appointments
|
||||||
|
WHERE tenant_id = $1 AND date BETWEEN CURRENT_DATE - INTERVAL '7 days' AND CURRENT_DATE
|
||||||
|
GROUP BY EXTRACT(DOW FROM date)
|
||||||
|
ORDER BY dow
|
||||||
|
`, [tenantId]);
|
||||||
|
|
||||||
|
// 6. Próximos horários livres
|
||||||
|
const professionalsRes = await query(`
|
||||||
|
SELECT id, name FROM users WHERE tenant_id = $1 AND role = 'professional' AND is_active = true
|
||||||
|
`, [tenantId]);
|
||||||
|
|
||||||
|
// Parse KPIs
|
||||||
|
const kpi = kpiTodayRes.rows[0];
|
||||||
|
const totalToday = Number(kpi.total_today);
|
||||||
|
const revenueToday = Number(kpi.revenue_today);
|
||||||
|
const totalClients = Number(clientsRes.rows[0].total_clients);
|
||||||
|
const thisWeek = Number(thisWeekRes.rows[0].total_this_week);
|
||||||
|
const lastWeek = Number(lastWeekRes.rows[0].total_last_week);
|
||||||
|
const weekChange = lastWeek > 0 ? Math.round(((thisWeek - lastWeek) / lastWeek) * 100) : 0;
|
||||||
|
|
||||||
|
// Taxa de ocupação (slots preenchidos / slots totais estimados)
|
||||||
|
const totalProfessionals = professionalsRes.rows.length || 1;
|
||||||
|
const slotsPerDay = 10 * totalProfessionals; // ~10 slots por profissional
|
||||||
|
const occupancy = slotsPerDay > 0 ? Math.min(100, Math.round((totalToday / slotsPerDay) * 100)) : 0;
|
||||||
|
|
||||||
|
// Desempenho semanal
|
||||||
|
const dayNames = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'];
|
||||||
|
const weeklyPerformance = [1, 2, 3, 4, 5, 6].map(dow => {
|
||||||
|
const found = weeklyRes.rows.find((r: any) => Number(r.dow) === dow);
|
||||||
|
const count = found ? Number(found.count) : 0;
|
||||||
|
const maxSlots = totalProfessionals * 10;
|
||||||
|
return {
|
||||||
|
label: dayNames[dow],
|
||||||
|
value: maxSlots > 0 ? Math.min(100, Math.round((count / maxSlots) * 100)) : 0,
|
||||||
|
count
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Appointments com formatação
|
||||||
|
const appointments = todayApptsRes.rows.map((a: any) => ({
|
||||||
|
id: a.id,
|
||||||
|
time: a.time,
|
||||||
|
client: a.client,
|
||||||
|
service: a.service,
|
||||||
|
professional: a.professional,
|
||||||
|
status: a.status,
|
||||||
|
price: Number(a.price)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
appointments,
|
||||||
|
kpis: {
|
||||||
|
appointmentsToday: totalToday,
|
||||||
|
revenueToday,
|
||||||
|
totalClients,
|
||||||
|
occupancy,
|
||||||
|
weekChange,
|
||||||
|
},
|
||||||
|
weeklyPerformance,
|
||||||
|
professionals: professionalsRes.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('GET Tenant Dashboard error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { query } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const slug = searchParams.get('slug');
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
return NextResponse.json({ error: 'Missing tenant slug' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await query(`
|
||||||
|
SELECT
|
||||||
|
t.name, t.slug, t.business_type, t.phone, t.email,
|
||||||
|
t.address, t.city, t.state, t.zip_code, t.description,
|
||||||
|
t.logo_url, t.cover_url, t.settings
|
||||||
|
FROM tenants t
|
||||||
|
WHERE t.slug = $1 AND t.is_active = true
|
||||||
|
`, [slug]);
|
||||||
|
|
||||||
|
if (res.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Estabelecimento não encontrado' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = res.rows[0];
|
||||||
|
const settings = tenant.settings || {};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
name: tenant.name,
|
||||||
|
slug: tenant.slug,
|
||||||
|
businessType: tenant.business_type,
|
||||||
|
phone: tenant.phone || '',
|
||||||
|
email: tenant.email || '',
|
||||||
|
address: tenant.address || '',
|
||||||
|
city: tenant.city || '',
|
||||||
|
state: tenant.state || '',
|
||||||
|
zipCode: tenant.zip_code || '',
|
||||||
|
description: tenant.description || '',
|
||||||
|
logoUrl: tenant.logo_url || '',
|
||||||
|
coverUrl: tenant.cover_url || '',
|
||||||
|
themeColor: settings.theme_color || '#6C63FF',
|
||||||
|
startTime: settings.start_time || '09:00',
|
||||||
|
endTime: settings.end_time || '19:00',
|
||||||
|
interval: settings.interval || 30,
|
||||||
|
workingDays: settings.working_days || [1, 2, 3, 4, 5, 6],
|
||||||
|
allowOnlineBooking: settings.allow_online_booking !== false,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('GET Profile error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { query } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const slug = searchParams.get('slug');
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
return NextResponse.json({ error: 'Missing tenant slug' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await query(`
|
||||||
|
SELECT
|
||||||
|
t.id, t.name, t.slug, t.business_type, t.phone, t.email,
|
||||||
|
t.address, t.city, t.state, t.zip_code, t.description,
|
||||||
|
t.logo_url, t.cover_url, t.settings,
|
||||||
|
t.subscription_status, t.is_active,
|
||||||
|
COALESCE(p.name, 'Starter') as plan_name
|
||||||
|
FROM tenants t
|
||||||
|
LEFT JOIN plans p ON t.plan_id = p.id
|
||||||
|
WHERE t.slug = $1
|
||||||
|
`, [slug]);
|
||||||
|
|
||||||
|
if (res.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Tenant not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = res.rows[0];
|
||||||
|
const settings = tenant.settings || {};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: tenant.id,
|
||||||
|
name: tenant.name,
|
||||||
|
slug: tenant.slug,
|
||||||
|
businessType: tenant.business_type,
|
||||||
|
phone: tenant.phone || '',
|
||||||
|
email: tenant.email || '',
|
||||||
|
address: tenant.address || '',
|
||||||
|
city: tenant.city || '',
|
||||||
|
state: tenant.state || '',
|
||||||
|
zipCode: tenant.zip_code || '',
|
||||||
|
description: tenant.description || '',
|
||||||
|
logoUrl: tenant.logo_url || '',
|
||||||
|
coverUrl: tenant.cover_url || '',
|
||||||
|
planName: tenant.plan_name,
|
||||||
|
subscriptionStatus: tenant.subscription_status,
|
||||||
|
// Settings JSONB
|
||||||
|
themeColor: settings.theme_color || '#6C63FF',
|
||||||
|
workingDays: settings.working_days || [1, 2, 3, 4, 5, 6],
|
||||||
|
startTime: settings.start_time || '09:00',
|
||||||
|
endTime: settings.end_time || '19:00',
|
||||||
|
interval: settings.interval || 30,
|
||||||
|
allowOnlineBooking: settings.allow_online_booking !== false,
|
||||||
|
requireConfirmation: settings.require_confirmation || false,
|
||||||
|
sendReminders: settings.send_reminders !== false,
|
||||||
|
reminderMinutesBefore: settings.reminder_minutes_before || 60,
|
||||||
|
allowCancellation: settings.allow_cancellation !== false,
|
||||||
|
bookingAdvanceMin: settings.booking_advance_min || 60,
|
||||||
|
bookingAdvanceMaxDays: settings.booking_advance_max_days || 30,
|
||||||
|
cancellationLimitMin: settings.cancellation_limit_min || 120,
|
||||||
|
notifyNewAppointment: settings.notify_new_appointment !== false,
|
||||||
|
notifyCancellation: settings.notify_cancellation !== false,
|
||||||
|
notifyClientReminder: settings.notify_client_reminder !== false,
|
||||||
|
dailySummary: settings.daily_summary || false,
|
||||||
|
weeklyReport: settings.weekly_report !== false,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('GET Settings error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { slug } = body;
|
||||||
|
|
||||||
|
if (!slug) return NextResponse.json({ error: 'Missing slug' }, { status: 400 });
|
||||||
|
|
||||||
|
// Build the settings JSONB object
|
||||||
|
const settingsJson = {
|
||||||
|
theme_color: body.themeColor || '#6C63FF',
|
||||||
|
working_days: body.workingDays || [1, 2, 3, 4, 5, 6],
|
||||||
|
start_time: body.startTime || '09:00',
|
||||||
|
end_time: body.endTime || '19:00',
|
||||||
|
interval: body.interval || 30,
|
||||||
|
allow_online_booking: body.allowOnlineBooking !== false,
|
||||||
|
require_confirmation: body.requireConfirmation || false,
|
||||||
|
send_reminders: body.sendReminders !== false,
|
||||||
|
reminder_minutes_before: body.reminderMinutesBefore || 60,
|
||||||
|
allow_cancellation: body.allowCancellation !== false,
|
||||||
|
booking_advance_min: body.bookingAdvanceMin || 60,
|
||||||
|
booking_advance_max_days: body.bookingAdvanceMaxDays || 30,
|
||||||
|
cancellation_limit_min: body.cancellationLimitMin || 120,
|
||||||
|
notify_new_appointment: body.notifyNewAppointment !== false,
|
||||||
|
notify_cancellation: body.notifyCancellation !== false,
|
||||||
|
notify_client_reminder: body.notifyClientReminder !== false,
|
||||||
|
daily_summary: body.dailySummary || false,
|
||||||
|
weekly_report: body.weeklyReport !== false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await query(`
|
||||||
|
UPDATE tenants SET
|
||||||
|
name = COALESCE($1, name),
|
||||||
|
business_type = COALESCE($2, business_type),
|
||||||
|
phone = $3,
|
||||||
|
email = $4,
|
||||||
|
address = $5,
|
||||||
|
city = $6,
|
||||||
|
state = $7,
|
||||||
|
zip_code = $8,
|
||||||
|
description = $9,
|
||||||
|
settings = $10,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE slug = $11
|
||||||
|
RETURNING id, name, slug
|
||||||
|
`, [
|
||||||
|
body.name, body.businessType,
|
||||||
|
body.phone || null, body.email || null,
|
||||||
|
body.address || null, body.city || null, body.state || null, body.zipCode || null,
|
||||||
|
body.description || null,
|
||||||
|
JSON.stringify(settingsJson),
|
||||||
|
slug
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (res.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Tenant not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, tenant: res.rows[0] });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('PUT Settings error:', error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,60 +1,95 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Calendar, Users, DollarSign, TrendingUp, Clock, ArrowUpRight, Play, CheckSquare } from 'lucide-react';
|
import { Calendar, Users, DollarSign, TrendingUp, Clock, ArrowUpRight, Play, CheckSquare, Loader } from 'lucide-react';
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
onNavigate: (page: string) => void;
|
onNavigate: (page: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const todayAppointments = [
|
|
||||||
{ id: 1, time: '10:00', client: 'João Mendes', service: 'Corte Masculino', professional: 'Carlos Silva', status: 'confirmed', price: 55 },
|
|
||||||
{ id: 2, time: '11:00', client: 'Pedro Oliveira', service: 'Combo Corte + Barba', professional: 'Carlos Silva', status: 'confirmed', price: 85 },
|
|
||||||
{ id: 3, time: '14:00', client: 'Lucas Ferreira', service: 'Barba Completa', professional: 'Rafael Santos', status: 'pending', price: 40 },
|
|
||||||
{ id: 4, time: '15:30', client: 'Mateus Costa', service: 'Corte Masculino', professional: 'Rafael Santos', status: 'confirmed', price: 55 },
|
|
||||||
{ id: 5, time: '16:00', client: 'Gabriel Souza', service: 'Design de Sobrancelha', professional: 'Carlos Silva', status: 'confirmed', price: 25 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusMap: Record<string, { label: string; cls: string }> = {
|
const statusMap: Record<string, { label: string; cls: string }> = {
|
||||||
confirmed: { label: 'Confirmado', cls: 'badge-info' },
|
confirmed: { label: 'Confirmado', cls: 'badge-info' },
|
||||||
pending: { label: 'Pendente', cls: 'badge-warning' },
|
pending: { label: 'Pendente', cls: 'badge-warning' },
|
||||||
completed: { label: 'Concluído', cls: 'badge-success' },
|
completed: { label: 'Concluído', cls: 'badge-success' },
|
||||||
cancelled: { label: 'Cancelado', cls: 'badge-danger' },
|
cancelled: { label: 'Cancelado', cls: 'badge-danger' },
|
||||||
serving: { label: 'Em Atendimento', cls: 'badge-purple' },
|
serving: { label: 'Em Atendimento', cls: 'badge-purple' },
|
||||||
|
in_progress: { label: 'Em Atendimento', cls: 'badge-purple' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Dashboard({ onNavigate }: DashboardProps) {
|
export default function Dashboard({ onNavigate }: DashboardProps) {
|
||||||
const [appointments, setAppointments] = useState(todayAppointments);
|
const [appointments, setAppointments] = useState<any[]>([]);
|
||||||
const [currentServingId, setCurrentServingId] = useState<number | null>(null);
|
const [kpis, setKpis] = useState({ appointmentsToday: 0, revenueToday: 0, totalClients: 0, occupancy: 0, weekChange: 0 });
|
||||||
const [isNew, setIsNew] = useState(false);
|
const [weeklyPerformance, setWeeklyPerformance] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentServingId, setCurrentServingId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedName = localStorage.getItem('agendapro_tenant_name');
|
const fetchDashboard = async () => {
|
||||||
if (savedName) {
|
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
||||||
setIsNew(true);
|
try {
|
||||||
setAppointments([]);
|
const res = await fetch(`/api/tenant/dashboard?slug=${slug}`);
|
||||||
}
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setAppointments(data.appointments || []);
|
||||||
|
setKpis(data.kpis || { appointmentsToday: 0, revenueToday: 0, totalClients: 0, occupancy: 0, weekChange: 0 });
|
||||||
|
setWeeklyPerformance(data.weeklyPerformance || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao carregar dashboard:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDashboard();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStartServing = (id: number) => {
|
const handleStartServing = async (id: string) => {
|
||||||
setCurrentServingId(id);
|
setCurrentServingId(id);
|
||||||
setAppointments(prev => prev.map(a => a.id === id ? { ...a, status: 'serving' } : a));
|
setAppointments(prev => prev.map(a => a.id === id ? { ...a, status: 'in_progress' } : a));
|
||||||
|
|
||||||
|
// Atualiza no banco
|
||||||
|
try {
|
||||||
|
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
||||||
|
await fetch('/api/tenant/agenda', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, status: 'in_progress', slug })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao iniciar atendimento:', err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFinishServing = (id: number) => {
|
const handleFinishServing = async (id: string) => {
|
||||||
setCurrentServingId(null);
|
setCurrentServingId(null);
|
||||||
setAppointments(prev => prev.map(a => a.id === id ? { ...a, status: 'completed' } : a));
|
setAppointments(prev => prev.map(a => a.id === id ? { ...a, status: 'completed' } : a));
|
||||||
|
|
||||||
|
// Atualiza no banco
|
||||||
|
try {
|
||||||
|
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
||||||
|
await fetch('/api/tenant/agenda', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, status: 'completed', slug })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao finalizar atendimento:', err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const servingApt = appointments.find(a => a.id === currentServingId);
|
const servingApt = appointments.find(a => a.id === currentServingId || a.status === 'in_progress');
|
||||||
const activeQueue = appointments.filter(a => a.status !== 'completed' && a.id !== currentServingId);
|
const activeQueue = appointments.filter(a => a.status !== 'completed' && a.status !== 'cancelled' && a.id !== servingApt?.id);
|
||||||
const nextInQueue = activeQueue[0];
|
const nextInQueue = activeQueue[0];
|
||||||
|
|
||||||
// Cálculos dinâmicos de estatísticas
|
const weekChangeLabel = kpis.weekChange > 0 ? `↑ ${kpis.weekChange}% vs semana anterior` : kpis.weekChange < 0 ? `↓ ${Math.abs(kpis.weekChange)}% vs semana anterior` : 'Sem dados da semana anterior';
|
||||||
const appointmentsCount = isNew ? appointments.length : 24;
|
|
||||||
const completedRevenue = appointments.filter(a => a.status === 'completed' || a.status === 'serving').reduce((s, a) => s + a.price, 0);
|
if (loading) {
|
||||||
const revenueValue = isNew ? completedRevenue : 2450;
|
return (
|
||||||
const activeClientsCount = isNew ? appointments.length : 156;
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '300px', color: 'var(--text-muted)' }}>
|
||||||
const occupancyPercentage = isNew ? (appointments.length > 0 ? Math.min(100, Math.round((appointments.length / 8) * 100)) : 0) : 92;
|
<Loader size={24} className="spin" style={{ marginRight: 8 }} /> Carregando dashboard...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="slide-up">
|
<div className="slide-up">
|
||||||
|
|
@ -69,6 +104,13 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
||||||
background: #a855f7 !important;
|
background: #a855f7 !important;
|
||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{/* Painel de Fila de Atendimento */}
|
{/* Painel de Fila de Atendimento */}
|
||||||
|
|
@ -82,7 +124,7 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
||||||
Atendendo agora: <span style={{ color: 'var(--accent)' }}>{servingApt.client}</span>
|
Atendendo agora: <span style={{ color: 'var(--accent)' }}>{servingApt.client}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginTop: '4px' }}>
|
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginTop: '4px' }}>
|
||||||
{servingApt.service} com {servingApt.professional} • R$ {servingApt.price.toFixed(2)}
|
{servingApt.service} com {servingApt.professional} • R$ {Number(servingApt.price).toFixed(2)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : nextInQueue ? (
|
) : nextInQueue ? (
|
||||||
|
|
@ -96,8 +138,12 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ fontSize: '16px', fontWeight: 700, color: 'var(--text-muted)' }}>Nenhum cliente na fila de espera</h3>
|
<h3 style={{ fontSize: '16px', fontWeight: 700, color: 'var(--text-muted)' }}>
|
||||||
<p style={{ fontSize: '13px', color: 'var(--text-muted)', marginTop: '4px' }}>Todos os agendamentos de hoje foram concluídos.</p>
|
{appointments.length === 0 ? 'Nenhum agendamento para hoje' : 'Todos os atendimentos do dia foram concluídos'}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--text-muted)', marginTop: '4px' }}>
|
||||||
|
{appointments.length === 0 ? 'Acesse a Agenda para criar novos agendamentos.' : 'Parabéns! Todos os clientes foram atendidos.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -115,7 +161,6 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sub-painel da Fila quando alguém já está sendo atendido */}
|
|
||||||
{servingApt && (
|
{servingApt && (
|
||||||
<div style={{ marginTop: '16px', paddingTop: '12px', borderTop: '1px solid var(--border-color)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', flexWrap: 'wrap', gap: '10px' }}>
|
<div style={{ marginTop: '16px', paddingTop: '12px', borderTop: '1px solid var(--border-color)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', flexWrap: 'wrap', gap: '10px' }}>
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>
|
<span style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
|
@ -137,33 +182,33 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-icon purple"><Calendar size={24} /></div>
|
<div className="stat-icon purple"><Calendar size={24} /></div>
|
||||||
<div>
|
<div>
|
||||||
<div className="stat-value">{appointmentsCount}</div>
|
<div className="stat-value">{kpis.appointmentsToday}</div>
|
||||||
<div className="stat-label">Agendamentos Hoje</div>
|
<div className="stat-label">Agendamentos Hoje</div>
|
||||||
<div className="stat-change up">{isNew ? '0% esta semana' : '↑ 12% vs semana anterior'}</div>
|
<div className="stat-change up">{weekChangeLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-icon green"><DollarSign size={24} /></div>
|
<div className="stat-icon green"><DollarSign size={24} /></div>
|
||||||
<div>
|
<div>
|
||||||
<div className="stat-value">R$ {revenueValue.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
|
<div className="stat-value">R$ {kpis.revenueToday.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
|
||||||
<div className="stat-label">Receita do Dia</div>
|
<div className="stat-label">Receita do Dia</div>
|
||||||
<div className="stat-change up">{isNew ? '0% hoje' : '↑ 8% vs ontem'}</div>
|
<div className="stat-change up">Valores confirmados</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-icon orange"><Users size={24} /></div>
|
<div className="stat-icon orange"><Users size={24} /></div>
|
||||||
<div>
|
<div>
|
||||||
<div className="stat-value">{activeClientsCount}</div>
|
<div className="stat-value">{kpis.totalClients}</div>
|
||||||
<div className="stat-label">Clientes Ativos</div>
|
<div className="stat-label">Clientes Cadastrados</div>
|
||||||
<div className="stat-change up">{isNew ? '0 novos clientes' : '↑ 5 novos esta semana'}</div>
|
<div className="stat-change up">Total ativo</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-icon blue"><TrendingUp size={24} /></div>
|
<div className="stat-icon blue"><TrendingUp size={24} /></div>
|
||||||
<div>
|
<div>
|
||||||
<div className="stat-value">{occupancyPercentage}%</div>
|
<div className="stat-value">{kpis.occupancy}%</div>
|
||||||
<div className="stat-label">Taxa de Ocupação</div>
|
<div className="stat-label">Taxa de Ocupação</div>
|
||||||
<div className="stat-change up">{isNew ? 'Com base na agenda' : '↑ 3% vs mês anterior'}</div>
|
<div className="stat-change up">Hoje</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -192,15 +237,16 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{appointments.map(apt => {
|
{appointments.map(apt => {
|
||||||
const isServing = apt.status === 'serving';
|
const isServing = apt.status === 'in_progress';
|
||||||
const isCompleted = apt.status === 'completed';
|
const isCompleted = apt.status === 'completed';
|
||||||
|
const st = statusMap[apt.status] || { label: apt.status, cls: 'badge-secondary' };
|
||||||
return (
|
return (
|
||||||
<tr key={apt.id} style={{ opacity: isCompleted ? 0.5 : 1, transition: 'opacity 0.3s' }}>
|
<tr key={apt.id} style={{ opacity: isCompleted ? 0.5 : 1, transition: 'opacity 0.3s' }}>
|
||||||
<td style={{ fontWeight: 600 }}>{apt.time}</td>
|
<td style={{ fontWeight: 600 }}>{apt.time}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex-gap gap-sm">
|
<div className="flex-gap gap-sm">
|
||||||
<div className="avatar" style={{ width: 28, height: 28, fontSize: 11 }}>
|
<div className="avatar" style={{ width: 28, height: 28, fontSize: 11 }}>
|
||||||
{apt.client.split(' ').map(n => n[0]).join('')}
|
{apt.client.split(' ').map((n: string) => n[0]).join('').substring(0, 2)}
|
||||||
</div>
|
</div>
|
||||||
{apt.client}
|
{apt.client}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -208,14 +254,21 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
||||||
<td>{apt.service}</td>
|
<td>{apt.service}</td>
|
||||||
<td>{apt.professional}</td>
|
<td>{apt.professional}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`badge ${statusMap[apt.status].cls} ${isServing ? 'pulse' : ''}`}>
|
<span className={`badge ${st.cls} ${isServing ? 'pulse' : ''}`}>
|
||||||
{statusMap[apt.status].label}
|
{st.label}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontWeight: 600 }}>R$ {apt.price.toFixed(2)}</td>
|
<td style={{ fontWeight: 600 }}>R$ {Number(apt.price).toFixed(2)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{appointments.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-muted)' }}>
|
||||||
|
Nenhum agendamento para hoje. Crie um novo na Agenda.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -225,14 +278,7 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="card-title mb-4">Desempenho Semanal</h3>
|
<h3 className="card-title mb-4">Desempenho Semanal</h3>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
{[
|
{weeklyPerformance.length > 0 ? weeklyPerformance.map((d: any) => (
|
||||||
{ label: 'Seg', value: isNew ? 0 : 85 },
|
|
||||||
{ label: 'Ter', value: isNew ? 0 : 72 },
|
|
||||||
{ label: 'Qua', value: isNew ? 0 : 90 },
|
|
||||||
{ label: 'Qui', value: isNew ? 0 : 65 },
|
|
||||||
{ label: 'Sex', value: isNew ? 0 : 95 },
|
|
||||||
{ label: 'Sáb', value: isNew ? 0 : 100 },
|
|
||||||
].map((d) => (
|
|
||||||
<div key={d.label} style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
<div key={d.label} style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
<span style={{ width: '30px', fontSize: '12px', color: 'var(--text-secondary)' }}>{d.label}</span>
|
<span style={{ width: '30px', fontSize: '12px', color: 'var(--text-secondary)' }}>{d.label}</span>
|
||||||
<div style={{ flex: 1, height: '8px', background: 'var(--bg-elevated)', borderRadius: '4px', overflow: 'hidden' }}>
|
<div style={{ flex: 1, height: '8px', background: 'var(--bg-elevated)', borderRadius: '4px', overflow: 'hidden' }}>
|
||||||
|
|
@ -243,30 +289,32 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
||||||
transition: 'width 1s ease'
|
transition: 'width 1s ease'
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: '12px', fontWeight: 600, width: '35px' }}>{d.value}%</span>
|
<span style={{ fontSize: '12px', fontWeight: 600, width: '35px' }}>{d.count || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)) : (
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--text-muted)', textAlign: 'center' }}>Sem dados suficientes</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="card-title mb-4">Próximos Horários Livres</h3>
|
<h3 className="card-title mb-4">Ações Rápidas</h3>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
{[
|
{[
|
||||||
{ time: '13:00 - 14:00', prof: 'Carlos Silva' },
|
{ label: 'Novo Agendamento', desc: 'Agendar um novo cliente', page: 'agenda' },
|
||||||
{ time: '14:30 - 15:30', prof: 'Carlos Silva' },
|
{ label: 'Cadastrar Cliente', desc: 'Adicionar cliente à base', page: 'clients' },
|
||||||
{ time: '16:00 - 17:00', prof: 'Rafael Santos' },
|
{ label: 'Novo Serviço', desc: 'Cadastrar um novo serviço', page: 'services' },
|
||||||
].map((slot, i) => (
|
].map((action, i) => (
|
||||||
<div key={i} style={{
|
<div key={i} style={{
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
padding: '10px 14px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)',
|
padding: '10px 14px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)',
|
||||||
border: '1px solid var(--border-color)'
|
border: '1px solid var(--border-color)'
|
||||||
}}>
|
}}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '14px', fontWeight: 600 }}>{slot.time}</div>
|
<div style={{ fontSize: '14px', fontWeight: 600 }}>{action.label}</div>
|
||||||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{slot.prof}</div>
|
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{action.desc}</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-sm btn-primary" onClick={() => onNavigate('agenda')}>Agendar</button>
|
<button className="btn btn-sm btn-primary" onClick={() => onNavigate(action.page)}>Ir</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Store, Clock, Globe, Bell, Palette, Shield, Save, Copy, ExternalLink, Check, Link } from 'lucide-react';
|
import { Store, Clock, Globe, Bell, Palette, Shield, Save, Copy, ExternalLink, Check, Link, Loader } from 'lucide-react';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'general', label: 'Geral', icon: Store },
|
{ id: 'general', label: 'Geral', icon: Store },
|
||||||
|
|
@ -23,107 +23,200 @@ export default function Settings() {
|
||||||
const [activeTab, setActiveTab] = useState('general');
|
const [activeTab, setActiveTab] = useState('general');
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
// States Dinâmicos do Tenant Ativo (Multi-Tenant Isolation)
|
// General
|
||||||
const [tenantName, setTenantName] = useState('Barbearia Premium');
|
const [tenantName, setTenantName] = useState('');
|
||||||
const [slug, setSlug] = useState('barbearia-premium');
|
const [slug, setSlug] = useState('');
|
||||||
const [phone, setPhone] = useState('(11) 99999-1234');
|
const [businessType, setBusinessType] = useState('barbearia');
|
||||||
const [email, setEmail] = useState('contato@barbeariapremium.com.br');
|
const [phone, setPhone] = useState('');
|
||||||
const [address, setAddress] = useState('Rua das Flores, 123 - Centro');
|
const [email, setEmail] = useState('');
|
||||||
const [city, setCity] = useState('São Paulo');
|
const [address, setAddress] = useState('');
|
||||||
|
const [city, setCity] = useState('');
|
||||||
const [state, setState] = useState('SP');
|
const [state, setState] = useState('SP');
|
||||||
const [zipCode, setZipCode] = useState('01001-000');
|
const [zipCode, setZipCode] = useState('');
|
||||||
const [description, setDescription] = useState('A melhor barbearia da cidade. Cortes modernos, barbas impecáveis e ambiente premium.');
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
// States para Horários de Expediente
|
// Hours
|
||||||
const [startTime, setStartTime] = useState('09:00');
|
const [startTime, setStartTime] = useState('09:00');
|
||||||
const [endTime, setEndTime] = useState('19:00');
|
const [endTime, setEndTime] = useState('19:00');
|
||||||
const [intervalTime, setIntervalTime] = useState(30);
|
const [intervalTime, setIntervalTime] = useState(30);
|
||||||
const [workingDays, setWorkingDays] = useState([false, true, true, true, true, true, true]); // Dom-Sab
|
const [workingDays, setWorkingDays] = useState([false, true, true, true, true, true, true]);
|
||||||
|
|
||||||
// Carrega dados dinâmicos do Tenant logado
|
// Booking
|
||||||
useEffect(() => {
|
|
||||||
const activeSlug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
|
||||||
setSlug(activeSlug);
|
|
||||||
|
|
||||||
const savedName = localStorage.getItem('agendapro_tenant_name') || 'Barbearia Premium';
|
|
||||||
setTenantName(savedName);
|
|
||||||
|
|
||||||
// Carrega dados isolados deste tenant específico
|
|
||||||
const savedPhone = localStorage.getItem(`agendapro_phone_${activeSlug}`) || (activeSlug === 'barbearia-premium' ? '(11) 99999-1234' : '');
|
|
||||||
const savedEmail = localStorage.getItem(`agendapro_email_${activeSlug}`) || (activeSlug === 'barbearia-premium' ? 'contato@barbeariapremium.com.br' : '');
|
|
||||||
const savedAddress = localStorage.getItem(`agendapro_address_${activeSlug}`) || (activeSlug === 'barbearia-premium' ? 'Rua das Flores, 123 - Centro' : '');
|
|
||||||
const savedCity = localStorage.getItem(`agendapro_city_${activeSlug}`) || (activeSlug === 'barbearia-premium' ? 'São Paulo' : '');
|
|
||||||
const savedState = localStorage.getItem(`agendapro_state_${activeSlug}`) || (activeSlug === 'barbearia-premium' ? 'SP' : 'SP');
|
|
||||||
const savedZip = localStorage.getItem(`agendapro_zip_${activeSlug}`) || (activeSlug === 'barbearia-premium' ? '01001-000' : '');
|
|
||||||
const savedDesc = localStorage.getItem(`agendapro_desc_${activeSlug}`) || (activeSlug === 'barbearia-premium' ? 'A melhor barbearia da cidade. Cortes modernos, barbas impecáveis e ambiente premium.' : 'Novo estabelecimento pronto para agendamentos online.');
|
|
||||||
|
|
||||||
setPhone(savedPhone);
|
|
||||||
setEmail(savedEmail);
|
|
||||||
setAddress(savedAddress);
|
|
||||||
setCity(savedCity);
|
|
||||||
setState(savedState);
|
|
||||||
setZipCode(savedZip);
|
|
||||||
setDescription(savedDesc);
|
|
||||||
|
|
||||||
// Horários isolados por tenant
|
|
||||||
const savedStart = localStorage.getItem(`agendapro_start_time_${activeSlug}`) || '09:00';
|
|
||||||
const savedEnd = localStorage.getItem(`agendapro_end_time_${activeSlug}`) || '19:00';
|
|
||||||
const savedInterval = Number(localStorage.getItem(`agendapro_interval_${activeSlug}`) || '30');
|
|
||||||
setStartTime(savedStart);
|
|
||||||
setEndTime(savedEnd);
|
|
||||||
setIntervalTime(savedInterval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// States para Reservas Online
|
|
||||||
const [allowOnline, setAllowOnline] = useState(true);
|
const [allowOnline, setAllowOnline] = useState(true);
|
||||||
const [requireConfirmation, setRequireConfirmation] = useState(false);
|
const [requireConfirmation, setRequireConfirmation] = useState(false);
|
||||||
const [autoReminders, setAutoReminders] = useState(true);
|
const [autoReminders, setAutoReminders] = useState(true);
|
||||||
const [allowCancel, setAllowCancel] = useState(true);
|
const [allowCancel, setAllowCancel] = useState(true);
|
||||||
|
const [bookingAdvanceMin, setBookingAdvanceMin] = useState(60);
|
||||||
|
const [bookingAdvanceMaxDays, setBookingAdvanceMaxDays] = useState(30);
|
||||||
|
const [cancellationLimitMin, setCancellationLimitMin] = useState(120);
|
||||||
|
|
||||||
// States para Notificações
|
// Notifications
|
||||||
const [notifyNew, setNotifyNew] = useState(true);
|
const [notifyNew, setNotifyNew] = useState(true);
|
||||||
const [notifyCancel, setNotifyCancel] = useState(true);
|
const [notifyCancel, setNotifyCancel] = useState(true);
|
||||||
const [notifyClient, setNotifyClient] = useState(true);
|
const [notifyClient, setNotifyClient] = useState(true);
|
||||||
const [dailySummary, setDailySummary] = useState(false);
|
const [dailySummary, setDailySummary] = useState(false);
|
||||||
const [weeklyReport, setWeeklyReport] = useState(true);
|
const [weeklyReport, setWeeklyReport] = useState(true);
|
||||||
|
const [reminderMinutes, setReminderMinutes] = useState(60);
|
||||||
|
|
||||||
|
// Appearance
|
||||||
|
const [themeColor, setThemeColor] = useState('#6C63FF');
|
||||||
|
|
||||||
|
// Security
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
||||||
|
const [securityError, setSecurityError] = useState('');
|
||||||
|
const [securitySuccess, setSecuritySuccess] = useState('');
|
||||||
|
|
||||||
|
// Carrega dados do banco de dados
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
const activeSlug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
||||||
|
setSlug(activeSlug);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tenant/settings?slug=${activeSlug}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setTenantName(data.name || '');
|
||||||
|
setBusinessType(data.businessType || 'barbearia');
|
||||||
|
setPhone(data.phone || '');
|
||||||
|
setEmail(data.email || '');
|
||||||
|
setAddress(data.address || '');
|
||||||
|
setCity(data.city || '');
|
||||||
|
setState(data.state || 'SP');
|
||||||
|
setZipCode(data.zipCode || '');
|
||||||
|
setDescription(data.description || '');
|
||||||
|
setThemeColor(data.themeColor || '#6C63FF');
|
||||||
|
setStartTime(data.startTime || '09:00');
|
||||||
|
setEndTime(data.endTime || '19:00');
|
||||||
|
setIntervalTime(data.interval || 30);
|
||||||
|
setAllowOnline(data.allowOnlineBooking !== false);
|
||||||
|
setRequireConfirmation(data.requireConfirmation || false);
|
||||||
|
setAutoReminders(data.sendReminders !== false);
|
||||||
|
setAllowCancel(data.allowCancellation !== false);
|
||||||
|
setBookingAdvanceMin(data.bookingAdvanceMin || 60);
|
||||||
|
setBookingAdvanceMaxDays(data.bookingAdvanceMaxDays || 30);
|
||||||
|
setCancellationLimitMin(data.cancellationLimitMin || 120);
|
||||||
|
setNotifyNew(data.notifyNewAppointment !== false);
|
||||||
|
setNotifyCancel(data.notifyCancellation !== false);
|
||||||
|
setNotifyClient(data.notifyClientReminder !== false);
|
||||||
|
setDailySummary(data.dailySummary || false);
|
||||||
|
setWeeklyReport(data.weeklyReport !== false);
|
||||||
|
setReminderMinutes(data.reminderMinutesBefore || 60);
|
||||||
|
|
||||||
|
// Converte workingDays [1,2,3,4,5,6] para array booleano [dom,seg,...,sab]
|
||||||
|
const wd = data.workingDays || [1, 2, 3, 4, 5, 6];
|
||||||
|
setWorkingDays([0, 1, 2, 3, 4, 5, 6].map(d => wd.includes(d)));
|
||||||
|
|
||||||
|
// Aplica cor do tema
|
||||||
|
if (data.themeColor) {
|
||||||
|
document.documentElement.style.setProperty('--accent', data.themeColor);
|
||||||
|
document.documentElement.style.setProperty('--accent-glow', `${data.themeColor}26`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao carregar configurações:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const publicUrl = typeof window !== 'undefined' ? `${window.location.origin}/${slug}/agendar` : `/${slug}/agendar`;
|
const publicUrl = typeof window !== 'undefined' ? `${window.location.origin}/${slug}/agendar` : `/${slug}/agendar`;
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
// 1. Atualiza dados de sessão do tenant
|
setSaving(true);
|
||||||
localStorage.setItem('agendapro_tenant_name', tenantName);
|
|
||||||
localStorage.setItem('agendapro_tenant_slug', slug);
|
|
||||||
|
|
||||||
// 2. Persiste dados com isolamento por slug (Tenant Isolation)
|
// Converte workingDays boolean[] para int[]
|
||||||
localStorage.setItem(`agendapro_phone_${slug}`, phone);
|
const wdInts = workingDays.map((active, i) => active ? i : -1).filter(i => i >= 0);
|
||||||
localStorage.setItem(`agendapro_email_${slug}`, email);
|
|
||||||
localStorage.setItem(`agendapro_address_${slug}`, address);
|
|
||||||
localStorage.setItem(`agendapro_city_${slug}`, city);
|
|
||||||
localStorage.setItem(`agendapro_state_${slug}`, state);
|
|
||||||
localStorage.setItem(`agendapro_zip_${slug}`, zipCode);
|
|
||||||
localStorage.setItem(`agendapro_desc_${slug}`, description);
|
|
||||||
|
|
||||||
// Horários isolados
|
try {
|
||||||
localStorage.setItem(`agendapro_start_time_${slug}`, startTime);
|
const res = await fetch('/api/tenant/settings', {
|
||||||
localStorage.setItem(`agendapro_end_time_${slug}`, endTime);
|
method: 'PUT',
|
||||||
localStorage.setItem(`agendapro_interval_${slug}`, String(intervalTime));
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
// Atualiza cookies dinâmicos para a página pública
|
slug,
|
||||||
document.cookie = `agendapro_tenant_slug=${slug}; path=/; max-age=2592000`;
|
name: tenantName,
|
||||||
document.cookie = `agendapro_start_time_${slug}=${startTime}; path=/; max-age=2592000`;
|
businessType,
|
||||||
document.cookie = `agendapro_end_time_${slug}=${endTime}; path=/; max-age=2592000`;
|
phone, email, address, city, state, zipCode, description,
|
||||||
document.cookie = `agendapro_interval_${slug}=${intervalTime}; path=/; max-age=2592000`;
|
themeColor,
|
||||||
|
workingDays: wdInts,
|
||||||
|
startTime, endTime,
|
||||||
|
interval: intervalTime,
|
||||||
|
allowOnlineBooking: allowOnline,
|
||||||
|
requireConfirmation,
|
||||||
|
sendReminders: autoReminders,
|
||||||
|
reminderMinutesBefore: reminderMinutes,
|
||||||
|
allowCancellation: allowCancel,
|
||||||
|
bookingAdvanceMin,
|
||||||
|
bookingAdvanceMaxDays,
|
||||||
|
cancellationLimitMin,
|
||||||
|
notifyNewAppointment: notifyNew,
|
||||||
|
notifyCancellation: notifyCancel,
|
||||||
|
notifyClientReminder: notifyClient,
|
||||||
|
dailySummary,
|
||||||
|
weeklyReport,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
// Dispara evento customizado para notificar sidebar e topbar sobre alteração de nome do estabelecimento
|
if (res.ok) {
|
||||||
if (typeof window !== 'undefined') {
|
// Atualiza localStorage para manter compatibilidade
|
||||||
window.dispatchEvent(new Event('storage'));
|
localStorage.setItem('agendapro_tenant_name', tenantName);
|
||||||
|
if (typeof window !== 'undefined') window.dispatchEvent(new Event('storage'));
|
||||||
|
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao salvar:', err);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
setSecurityError('');
|
||||||
|
setSecuritySuccess('');
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
setSecurityError('Preencha todos os campos.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmNewPassword) {
|
||||||
|
setSecurityError('A nova senha e a confirmação não conferem.');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaved(true);
|
try {
|
||||||
setTimeout(() => setSaved(false), 2000);
|
const token = localStorage.getItem('agendapro_token');
|
||||||
|
const res = await fetch('/api/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token, currentPassword, newPassword })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setSecuritySuccess('Senha alterada com sucesso!');
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmNewPassword('');
|
||||||
|
} else {
|
||||||
|
setSecurityError(data.error || 'Erro ao alterar senha.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setSecurityError('Falha de conexão com o servidor.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="p-8 text-center" style={{color: 'var(--text-muted)'}}>Carregando configurações...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="slide-up">
|
<div className="slide-up">
|
||||||
<div className="tabs">
|
<div className="tabs">
|
||||||
|
|
@ -150,13 +243,14 @@ export default function Settings() {
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Tipo de Negócio</label>
|
<label className="form-label">Tipo de Negócio</label>
|
||||||
<select className="form-select" defaultValue="barbearia">
|
<select className="form-select" value={businessType} onChange={e => setBusinessType(e.target.value)}>
|
||||||
{businessTypes.map(bt => <option key={bt.value} value={bt.value}>{bt.label}</option>)}
|
{businessTypes.map(bt => <option key={bt.value} value={bt.value}>{bt.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Slug (URL)</label>
|
<label className="form-label">Slug (URL)</label>
|
||||||
<input className="form-input" value={slug} onChange={e => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))} />
|
<input className="form-input" value={slug} readOnly style={{ opacity: 0.6 }}
|
||||||
|
title="O slug é definido na criação do tenant e não pode ser alterado" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
|
|
@ -181,12 +275,9 @@ export default function Settings() {
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Estado</label>
|
<label className="form-label">Estado</label>
|
||||||
<select className="form-select" value={state} onChange={e => setState(e.target.value)}>
|
<select className="form-select" value={state} onChange={e => setState(e.target.value)}>
|
||||||
<option value="SP">SP</option>
|
{['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO'].map(uf => (
|
||||||
<option value="RJ">RJ</option>
|
<option key={uf} value={uf}>{uf}</option>
|
||||||
<option value="MG">MG</option>
|
))}
|
||||||
<option value="SC">SC</option>
|
|
||||||
<option value="RS">RS</option>
|
|
||||||
<option value="PR">PR</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|
@ -199,8 +290,8 @@ export default function Settings() {
|
||||||
<textarea className="form-textarea" value={description} onChange={e => setDescription(e.target.value)} />
|
<textarea className="form-textarea" value={description} onChange={e => setDescription(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: '24px' }}>
|
<div style={{ marginTop: '24px' }}>
|
||||||
<button className="btn btn-primary" onClick={handleSave}>
|
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||||
<Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar Alterações'}
|
{saving ? <><Loader size={16} className="spin" /> Salvando...</> : <><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar Alterações'}</>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -235,7 +326,9 @@ export default function Settings() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: '24px' }}>
|
<div style={{ marginTop: '24px' }}>
|
||||||
<button className="btn btn-primary" onClick={handleSave}><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar Alterações'}</button>
|
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? <><Loader size={16} className="spin" /> Salvando...</> : <><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar Alterações'}</>}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -256,6 +349,11 @@ export default function Settings() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||||
|
<Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -292,12 +390,6 @@ export default function Settings() {
|
||||||
<ExternalLink size={14} /> Abrir
|
<ExternalLink size={14} /> Abrir
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group" style={{ marginTop: '16px', marginBottom: 0 }}>
|
|
||||||
<label className="form-label">Slug do estabelecimento (usado na URL)</label>
|
|
||||||
<input className="form-input" value={slug} onChange={e => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/--+/g, '-'))}
|
|
||||||
style={{ maxWidth: '400px' }} placeholder="meu-estabelecimento" />
|
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '4px' }}>Apenas letras minúsculas, números e hífens</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* BOOKING OPTIONS */}
|
{/* BOOKING OPTIONS */}
|
||||||
|
|
@ -324,18 +416,20 @@ export default function Settings() {
|
||||||
<div className="grid-2 mt-6">
|
<div className="grid-2 mt-6">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Antecedência mínima (min)</label>
|
<label className="form-label">Antecedência mínima (min)</label>
|
||||||
<input className="form-input" type="number" defaultValue={60} />
|
<input className="form-input" type="number" value={bookingAdvanceMin} onChange={e => setBookingAdvanceMin(Number(e.target.value))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Antecedência máxima (dias)</label>
|
<label className="form-label">Antecedência máxima (dias)</label>
|
||||||
<input className="form-input" type="number" defaultValue={30} />
|
<input className="form-input" type="number" value={bookingAdvanceMaxDays} onChange={e => setBookingAdvanceMaxDays(Number(e.target.value))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Limite para cancelamento (minutos antes)</label>
|
<label className="form-label">Limite para cancelamento (minutos antes)</label>
|
||||||
<input className="form-input" type="number" defaultValue={120} />
|
<input className="form-input" type="number" value={cancellationLimitMin} onChange={e => setCancellationLimitMin(Number(e.target.value))} />
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary mt-4" onClick={handleSave}><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}</button>
|
<button className="btn btn-primary mt-4" onClick={handleSave} disabled={saving}>
|
||||||
|
<Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -364,9 +458,11 @@ export default function Settings() {
|
||||||
))}
|
))}
|
||||||
<div className="form-group mt-6">
|
<div className="form-group mt-6">
|
||||||
<label className="form-label">Tempo do lembrete (minutos antes)</label>
|
<label className="form-label">Tempo do lembrete (minutos antes)</label>
|
||||||
<input className="form-input" type="number" defaultValue={60} style={{ width: '200px' }} />
|
<input className="form-input" type="number" value={reminderMinutes} onChange={e => setReminderMinutes(Number(e.target.value))} style={{ width: '200px' }} />
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary mt-4" onClick={handleSave}><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}</button>
|
<button className="btn btn-primary mt-4" onClick={handleSave} disabled={saving}>
|
||||||
|
<Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -379,41 +475,49 @@ export default function Settings() {
|
||||||
{['#6C63FF', '#a855f7', '#ec4899', '#f59e0b', '#22c55e', '#3b82f6', '#ef4444'].map(c => (
|
{['#6C63FF', '#a855f7', '#ec4899', '#f59e0b', '#22c55e', '#3b82f6', '#ef4444'].map(c => (
|
||||||
<div key={c} style={{
|
<div key={c} style={{
|
||||||
width: 40, height: 40, borderRadius: 'var(--radius-sm)', background: c,
|
width: 40, height: 40, borderRadius: 'var(--radius-sm)', background: c,
|
||||||
cursor: 'pointer', border: '2px solid transparent', transition: 'all 0.2s'
|
cursor: 'pointer', border: themeColor === c ? '3px solid white' : '2px solid transparent',
|
||||||
|
transition: 'all 0.2s', boxShadow: themeColor === c ? `0 0 0 2px ${c}` : 'none'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.transform = 'scale(1.1)'}
|
onMouseEnter={e => (e.currentTarget as HTMLElement).style.transform = 'scale(1.1)'}
|
||||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.transform = 'scale(1)'}
|
onMouseLeave={e => (e.currentTarget as HTMLElement).style.transform = 'scale(1)'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setThemeColor(c);
|
||||||
document.documentElement.style.setProperty('--accent', c);
|
document.documentElement.style.setProperty('--accent', c);
|
||||||
document.documentElement.style.setProperty('--accent-glow', `${c}26`); // 15% opacity hex
|
document.documentElement.style.setProperty('--accent-glow', `${c}26`);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '8px' }}>Clique em uma cor para aplicar imediatamente ao seu painel.</p>
|
<p style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '8px' }}>Clique em uma cor para aplicar. A cor será salva no banco de dados.</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary mt-4" onClick={handleSave}><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}</button>
|
<button className="btn btn-primary mt-4" onClick={handleSave} disabled={saving}>
|
||||||
|
<Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'security' && (
|
{activeTab === 'security' && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="card-title mb-4">Segurança</h3>
|
<h3 className="card-title mb-4">Alterar Senha de Acesso</h3>
|
||||||
|
{securityError && <div style={{ background: 'var(--danger)', color: 'white', padding: '12px', borderRadius: 'var(--radius-md)', marginBottom: '16px', fontSize: '14px' }}>{securityError}</div>}
|
||||||
|
{securitySuccess && <div style={{ background: 'var(--success)', color: 'white', padding: '12px', borderRadius: 'var(--radius-md)', marginBottom: '16px', fontSize: '14px' }}>{securitySuccess}</div>}
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Senha Atual</label>
|
<label className="form-label">Senha Atual</label>
|
||||||
<input className="form-input" type="password" placeholder="••••••••" />
|
<input className="form-input" type="password" placeholder="••••••••" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Nova Senha</label>
|
<label className="form-label">Nova Senha</label>
|
||||||
<input className="form-input" type="password" placeholder="••••••••" />
|
<input className="form-input" type="password" placeholder="••••••••" value={newPassword} onChange={e => setNewPassword(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Confirmar Nova Senha</label>
|
<label className="form-label">Confirmar Nova Senha</label>
|
||||||
<input className="form-input" type="password" placeholder="••••••••" />
|
<input className="form-input" type="password" placeholder="••••••••" value={confirmNewPassword} onChange={e => setConfirmNewPassword(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary mt-4" onClick={handleSave}><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}</button>
|
<button className="btn btn-primary mt-4" onClick={handleChangePassword}>
|
||||||
|
<Shield size={16} /> Alterar Senha
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue