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

This commit is contained in:
Sidney 2026-06-07 21:56:30 -03:00
parent d2f0670ce7
commit 959c6f6039
7 changed files with 816 additions and 269 deletions

View File

@ -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>
))} ))}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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>

View File

@ -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>