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';
|
||||
import { useState, useEffect, use } from 'react';
|
||||
import { Calendar, Clock, User, Phone, Mail, ChevronLeft, ChevronRight, Check, Scissors, MapPin, Star } 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 },
|
||||
];
|
||||
import { Calendar, Clock, User, Phone, Mail, ChevronLeft, ChevronRight, Check, Scissors, MapPin, Star, Loader } from 'lucide-react';
|
||||
|
||||
export default function PublicBookingPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = use(params);
|
||||
|
|
@ -38,25 +15,21 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
|||
const [clientEmail, setClientEmail] = useState('');
|
||||
const [booked, setBooked] = useState(false);
|
||||
const [slots, setSlots] = useState<string[]>([]);
|
||||
const [loadingDb, setLoadingDb] = useState(true);
|
||||
|
||||
// Carrega e gera os slots dinâmicos do barbeiro
|
||||
useEffect(() => {
|
||||
const getCookie = (name: string): string => {
|
||||
if (typeof document === 'undefined') return '';
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop()!.split(';').shift() || '';
|
||||
return '';
|
||||
};
|
||||
const [dbServices, setDbServices] = useState<any[]>([]);
|
||||
const [dbProfessionals, setDbProfessionals] = useState<any[]>([]);
|
||||
|
||||
const start = getCookie(`agendapro_start_time_${slug}`) || localStorage.getItem(`agendapro_start_time_${slug}`) || '09:00';
|
||||
const end = getCookie(`agendapro_end_time_${slug}`) || localStorage.getItem(`agendapro_end_time_${slug}`) || '19:00';
|
||||
const interval = Number(getCookie(`agendapro_interval_${slug}`) || localStorage.getItem(`agendapro_interval_${slug}`) || '30');
|
||||
// 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 = [];
|
||||
let [sh, sm] = startTimeStr.split(':').map(Number);
|
||||
const generated: string[] = [];
|
||||
const [sh, sm] = startTimeStr.split(':').map(Number);
|
||||
const [eh, em] = endTimeStr.split(':').map(Number);
|
||||
|
||||
let currentMinutes = sh * 60 + sm;
|
||||
|
|
@ -72,7 +45,61 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
|||
return generated;
|
||||
};
|
||||
|
||||
setSlots(generateSlots(start, end, interval));
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [profileRes, agendaRes, servicesRes] = await Promise.all([
|
||||
fetch(`/api/tenant/profile?slug=${slug}`),
|
||||
fetch(`/api/tenant/agenda?slug=${slug}`),
|
||||
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));
|
||||
}
|
||||
|
||||
if (agendaRes.ok) {
|
||||
const data = await agendaRes.json();
|
||||
setDbProfessionals(data.professionals || []);
|
||||
}
|
||||
if (servicesRes.ok) {
|
||||
const data = await servicesRes.json();
|
||||
setDbServices(data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao buscar dados do banco:', err);
|
||||
setSlots(generateSlots('09:00', '19:00', 30));
|
||||
} finally {
|
||||
setLoadingDb(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [slug]);
|
||||
|
||||
const today = new Date();
|
||||
|
|
@ -82,8 +109,8 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
|||
return d;
|
||||
});
|
||||
|
||||
const svc = services.find(s => s.id === selectedService);
|
||||
const prof = professionals.find(p => p.id === selectedProfessional);
|
||||
const svc = dbServices.find(s => s.id === selectedService);
|
||||
const prof = dbProfessionals.find(p => p.id === selectedProfessional);
|
||||
|
||||
const canNext = () => {
|
||||
if (step === 1) return !!selectedService;
|
||||
|
|
@ -93,11 +120,15 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
|||
return false;
|
||||
};
|
||||
|
||||
const handleBook = () => {
|
||||
// 1. Monta o objeto de agendamento no formato esperado pela Agenda
|
||||
const handleBook = async () => {
|
||||
const [startHour, startMin] = selectedTime ? selectedTime.split(':').map(Number) : [9, 0];
|
||||
const newAppointment = {
|
||||
id: Date.now().toString(),
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/tenant/agenda', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug,
|
||||
professionalId: selectedProfessional || '1',
|
||||
client: clientName,
|
||||
phone: clientPhone,
|
||||
|
|
@ -107,30 +138,19 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
|||
duration: svc?.duration || 30,
|
||||
startHour,
|
||||
startMin,
|
||||
status: 'pending', // Inicia pendente de aprovação!
|
||||
date: selectedDate, // Data do agendamento (ex: "2026-05-31")
|
||||
};
|
||||
status: 'pending',
|
||||
date: selectedDate,
|
||||
})
|
||||
});
|
||||
|
||||
// 2. Salva no localStorage com isolamento por slug (Tenant Isolation)
|
||||
const key = `agendapro_appointments_${slug}`;
|
||||
const existing = localStorage.getItem(key);
|
||||
let currentList = [];
|
||||
if (existing) {
|
||||
currentList = JSON.parse(existing);
|
||||
} else {
|
||||
// Se não existia e for barbearia premium demo, pega os mockAppointments padrão
|
||||
currentList = slug === 'barbearia-premium' ? [
|
||||
{ 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] },
|
||||
{ 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] },
|
||||
{ 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] },
|
||||
{ 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] },
|
||||
{ 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] }
|
||||
] : [];
|
||||
}
|
||||
|
||||
currentList.push(newAppointment);
|
||||
localStorage.setItem(key, JSON.stringify(currentList));
|
||||
if (res.ok) {
|
||||
setBooked(true);
|
||||
} else {
|
||||
console.error('Falha ao agendar no banco.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (booked) {
|
||||
|
|
@ -223,7 +243,7 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
|||
<div className="slide-up">
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '20px' }}>Escolha o serviço</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{services.map(s => (
|
||||
{dbServices.map(s => (
|
||||
<div key={s.id} onClick={() => setSelectedService(s.id)} style={{
|
||||
padding: '18px 20px', borderRadius: 'var(--radius-md)', cursor: 'pointer',
|
||||
background: selectedService === s.id ? 'var(--accent-glow)' : 'var(--bg-card)',
|
||||
|
|
@ -232,13 +252,13 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
|||
}}>
|
||||
<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' }}>
|
||||
<Clock size={12} /> {s.duration} min
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
|
|
@ -251,20 +271,20 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
|||
<div className="slide-up">
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '20px' }}>Escolha o profissional</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{professionals.map(p => (
|
||||
{dbProfessionals.map(p => (
|
||||
<div key={p.id} onClick={() => setSelectedProfessional(p.id)} style={{
|
||||
padding: '20px', borderRadius: 'var(--radius-md)', cursor: 'pointer',
|
||||
background: selectedProfessional === p.id ? 'var(--accent-glow)' : 'var(--bg-card)',
|
||||
border: `2px solid ${selectedProfessional === p.id ? 'var(--accent)' : 'var(--border-color)'}`,
|
||||
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={{ 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 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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
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 {
|
||||
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 }> = {
|
||||
confirmed: { label: 'Confirmado', cls: 'badge-info' },
|
||||
pending: { label: 'Pendente', cls: 'badge-warning' },
|
||||
completed: { label: 'Concluído', cls: 'badge-success' },
|
||||
cancelled: { label: 'Cancelado', cls: 'badge-danger' },
|
||||
serving: { label: 'Em Atendimento', cls: 'badge-purple' },
|
||||
in_progress: { label: 'Em Atendimento', cls: 'badge-purple' },
|
||||
};
|
||||
|
||||
export default function Dashboard({ onNavigate }: DashboardProps) {
|
||||
const [appointments, setAppointments] = useState(todayAppointments);
|
||||
const [currentServingId, setCurrentServingId] = useState<number | null>(null);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
const [appointments, setAppointments] = useState<any[]>([]);
|
||||
const [kpis, setKpis] = useState({ appointmentsToday: 0, revenueToday: 0, totalClients: 0, occupancy: 0, weekChange: 0 });
|
||||
const [weeklyPerformance, setWeeklyPerformance] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentServingId, setCurrentServingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const savedName = localStorage.getItem('agendapro_tenant_name');
|
||||
if (savedName) {
|
||||
setIsNew(true);
|
||||
setAppointments([]);
|
||||
const fetchDashboard = async () => {
|
||||
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
|
||||
try {
|
||||
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);
|
||||
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);
|
||||
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 activeQueue = appointments.filter(a => a.status !== 'completed' && a.id !== currentServingId);
|
||||
const servingApt = appointments.find(a => a.id === currentServingId || a.status === 'in_progress');
|
||||
const activeQueue = appointments.filter(a => a.status !== 'completed' && a.status !== 'cancelled' && a.id !== servingApt?.id);
|
||||
const nextInQueue = activeQueue[0];
|
||||
|
||||
// Cálculos dinâmicos de estatísticas
|
||||
const appointmentsCount = isNew ? appointments.length : 24;
|
||||
const completedRevenue = appointments.filter(a => a.status === 'completed' || a.status === 'serving').reduce((s, a) => s + a.price, 0);
|
||||
const revenueValue = isNew ? completedRevenue : 2450;
|
||||
const activeClientsCount = isNew ? appointments.length : 156;
|
||||
const occupancyPercentage = isNew ? (appointments.length > 0 ? Math.min(100, Math.round((appointments.length / 8) * 100)) : 0) : 92;
|
||||
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';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '300px', color: 'var(--text-muted)' }}>
|
||||
<Loader size={24} className="spin" style={{ marginRight: 8 }} /> Carregando dashboard...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="slide-up">
|
||||
|
|
@ -69,6 +104,13 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
|||
background: #a855f7 !important;
|
||||
color: white !important;
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 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>
|
||||
</h3>
|
||||
<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>
|
||||
</div>
|
||||
) : nextInQueue ? (
|
||||
|
|
@ -96,8 +138,12 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
|||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 700, color: 'var(--text-muted)' }}>Nenhum cliente na fila de espera</h3>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text-muted)', marginTop: '4px' }}>Todos os agendamentos de hoje foram concluídos.</p>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 700, color: 'var(--text-muted)' }}>
|
||||
{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>
|
||||
|
|
@ -115,7 +161,6 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-painel da Fila quando alguém já está sendo atendido */}
|
||||
{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' }}>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>
|
||||
|
|
@ -137,33 +182,33 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
|||
<div className="stat-card">
|
||||
<div className="stat-icon purple"><Calendar size={24} /></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-change up">{isNew ? '0% esta semana' : '↑ 12% vs semana anterior'}</div>
|
||||
<div className="stat-change up">{weekChangeLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon green"><DollarSign size={24} /></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-change up">{isNew ? '0% hoje' : '↑ 8% vs ontem'}</div>
|
||||
<div className="stat-change up">Valores confirmados</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon orange"><Users size={24} /></div>
|
||||
<div>
|
||||
<div className="stat-value">{activeClientsCount}</div>
|
||||
<div className="stat-label">Clientes Ativos</div>
|
||||
<div className="stat-change up">{isNew ? '0 novos clientes' : '↑ 5 novos esta semana'}</div>
|
||||
<div className="stat-value">{kpis.totalClients}</div>
|
||||
<div className="stat-label">Clientes Cadastrados</div>
|
||||
<div className="stat-change up">Total ativo</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon blue"><TrendingUp size={24} /></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-change up">{isNew ? 'Com base na agenda' : '↑ 3% vs mês anterior'}</div>
|
||||
<div className="stat-change up">Hoje</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -192,15 +237,16 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
|||
</thead>
|
||||
<tbody>
|
||||
{appointments.map(apt => {
|
||||
const isServing = apt.status === 'serving';
|
||||
const isServing = apt.status === 'in_progress';
|
||||
const isCompleted = apt.status === 'completed';
|
||||
const st = statusMap[apt.status] || { label: apt.status, cls: 'badge-secondary' };
|
||||
return (
|
||||
<tr key={apt.id} style={{ opacity: isCompleted ? 0.5 : 1, transition: 'opacity 0.3s' }}>
|
||||
<td style={{ fontWeight: 600 }}>{apt.time}</td>
|
||||
<td>
|
||||
<div className="flex-gap gap-sm">
|
||||
<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>
|
||||
{apt.client}
|
||||
</div>
|
||||
|
|
@ -208,14 +254,21 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
|||
<td>{apt.service}</td>
|
||||
<td>{apt.professional}</td>
|
||||
<td>
|
||||
<span className={`badge ${statusMap[apt.status].cls} ${isServing ? 'pulse' : ''}`}>
|
||||
{statusMap[apt.status].label}
|
||||
<span className={`badge ${st.cls} ${isServing ? 'pulse' : ''}`}>
|
||||
{st.label}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>R$ {apt.price.toFixed(2)}</td>
|
||||
<td style={{ fontWeight: 600 }}>R$ {Number(apt.price).toFixed(2)}</td>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -225,14 +278,7 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
|||
<div className="card">
|
||||
<h3 className="card-title mb-4">Desempenho Semanal</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{[
|
||||
{ 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) => (
|
||||
{weeklyPerformance.length > 0 ? weeklyPerformance.map((d: any) => (
|
||||
<div key={d.label} style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<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' }}>
|
||||
|
|
@ -243,30 +289,32 @@ export default function Dashboard({ onNavigate }: DashboardProps) {
|
|||
transition: 'width 1s ease'
|
||||
}} />
|
||||
</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>
|
||||
))}
|
||||
)) : (
|
||||
<p style={{ fontSize: '13px', color: 'var(--text-muted)', textAlign: 'center' }}>Sem dados suficientes</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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' }}>
|
||||
{[
|
||||
{ time: '13:00 - 14:00', prof: 'Carlos Silva' },
|
||||
{ time: '14:30 - 15:30', prof: 'Carlos Silva' },
|
||||
{ time: '16:00 - 17:00', prof: 'Rafael Santos' },
|
||||
].map((slot, i) => (
|
||||
{ label: 'Novo Agendamento', desc: 'Agendar um novo cliente', page: 'agenda' },
|
||||
{ label: 'Cadastrar Cliente', desc: 'Adicionar cliente à base', page: 'clients' },
|
||||
{ label: 'Novo Serviço', desc: 'Cadastrar um novo serviço', page: 'services' },
|
||||
].map((action, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '10px 14px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border-color)'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600 }}>{slot.time}</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{slot.prof}</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600 }}>{action.label}</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{action.desc}</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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 = [
|
||||
{ id: 'general', label: 'Geral', icon: Store },
|
||||
|
|
@ -23,107 +23,200 @@ export default function Settings() {
|
|||
const [activeTab, setActiveTab] = useState('general');
|
||||
const [saved, setSaved] = 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)
|
||||
const [tenantName, setTenantName] = useState('Barbearia Premium');
|
||||
const [slug, setSlug] = useState('barbearia-premium');
|
||||
const [phone, setPhone] = useState('(11) 99999-1234');
|
||||
const [email, setEmail] = useState('contato@barbeariapremium.com.br');
|
||||
const [address, setAddress] = useState('Rua das Flores, 123 - Centro');
|
||||
const [city, setCity] = useState('São Paulo');
|
||||
// General
|
||||
const [tenantName, setTenantName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [businessType, setBusinessType] = useState('barbearia');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [city, setCity] = useState('');
|
||||
const [state, setState] = useState('SP');
|
||||
const [zipCode, setZipCode] = useState('01001-000');
|
||||
const [description, setDescription] = useState('A melhor barbearia da cidade. Cortes modernos, barbas impecáveis e ambiente premium.');
|
||||
const [zipCode, setZipCode] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
// States para Horários de Expediente
|
||||
// Hours
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
const [endTime, setEndTime] = useState('19:00');
|
||||
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
|
||||
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
|
||||
// Booking
|
||||
const [allowOnline, setAllowOnline] = useState(true);
|
||||
const [requireConfirmation, setRequireConfirmation] = useState(false);
|
||||
const [autoReminders, setAutoReminders] = 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 [notifyCancel, setNotifyCancel] = useState(true);
|
||||
const [notifyClient, setNotifyClient] = useState(true);
|
||||
const [dailySummary, setDailySummary] = useState(false);
|
||||
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 handleSave = () => {
|
||||
// 1. Atualiza dados de sessão do tenant
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
|
||||
// Converte workingDays boolean[] para int[]
|
||||
const wdInts = workingDays.map((active, i) => active ? i : -1).filter(i => i >= 0);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/tenant/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug,
|
||||
name: tenantName,
|
||||
businessType,
|
||||
phone, email, address, city, state, zipCode, description,
|
||||
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,
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Atualiza localStorage para manter compatibilidade
|
||||
localStorage.setItem('agendapro_tenant_name', tenantName);
|
||||
localStorage.setItem('agendapro_tenant_slug', slug);
|
||||
|
||||
// 2. Persiste dados com isolamento por slug (Tenant Isolation)
|
||||
localStorage.setItem(`agendapro_phone_${slug}`, phone);
|
||||
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
|
||||
localStorage.setItem(`agendapro_start_time_${slug}`, startTime);
|
||||
localStorage.setItem(`agendapro_end_time_${slug}`, endTime);
|
||||
localStorage.setItem(`agendapro_interval_${slug}`, String(intervalTime));
|
||||
|
||||
// Atualiza cookies dinâmicos para a página pública
|
||||
document.cookie = `agendapro_tenant_slug=${slug}; path=/; max-age=2592000`;
|
||||
document.cookie = `agendapro_start_time_${slug}=${startTime}; path=/; max-age=2592000`;
|
||||
document.cookie = `agendapro_end_time_${slug}=${endTime}; path=/; max-age=2592000`;
|
||||
document.cookie = `agendapro_interval_${slug}=${intervalTime}; path=/; max-age=2592000`;
|
||||
|
||||
// Dispara evento customizado para notificar sidebar e topbar sobre alteração de nome do estabelecimento
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event('storage'));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
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 (
|
||||
<div className="slide-up">
|
||||
<div className="tabs">
|
||||
|
|
@ -150,13 +243,14 @@ export default function Settings() {
|
|||
<div className="grid-2">
|
||||
<div className="form-group">
|
||||
<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>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<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 className="grid-2">
|
||||
|
|
@ -181,12 +275,9 @@ export default function Settings() {
|
|||
<div className="form-group">
|
||||
<label className="form-label">Estado</label>
|
||||
<select className="form-select" value={state} onChange={e => setState(e.target.value)}>
|
||||
<option value="SP">SP</option>
|
||||
<option value="RJ">RJ</option>
|
||||
<option value="MG">MG</option>
|
||||
<option value="SC">SC</option>
|
||||
<option value="RS">RS</option>
|
||||
<option value="PR">PR</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 key={uf} value={uf}>{uf}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
|
|
@ -199,8 +290,8 @@ export default function Settings() {
|
|||
<textarea className="form-textarea" value={description} onChange={e => setDescription(e.target.value)} />
|
||||
</div>
|
||||
<div style={{ marginTop: '24px' }}>
|
||||
<button className="btn btn-primary" onClick={handleSave}>
|
||||
<Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar Alterações'}
|
||||
<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>
|
||||
|
|
@ -235,7 +326,9 @@ export default function Settings() {
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
|
|
@ -256,6 +349,11 @@ export default function Settings() {
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
<Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -292,12 +390,6 @@ export default function Settings() {
|
|||
<ExternalLink size={14} /> Abrir
|
||||
</a>
|
||||
</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>
|
||||
|
||||
{/* BOOKING OPTIONS */}
|
||||
|
|
@ -324,18 +416,20 @@ export default function Settings() {
|
|||
<div className="grid-2 mt-6">
|
||||
<div className="form-group">
|
||||
<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 className="form-group">
|
||||
<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 className="form-group">
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
|
@ -364,9 +458,11 @@ export default function Settings() {
|
|||
))}
|
||||
<div className="form-group mt-6">
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
@ -379,41 +475,49 @@ export default function Settings() {
|
|||
{['#6C63FF', '#a855f7', '#ec4899', '#f59e0b', '#22c55e', '#3b82f6', '#ef4444'].map(c => (
|
||||
<div key={c} style={{
|
||||
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)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.transform = 'scale(1)'}
|
||||
onClick={() => {
|
||||
setThemeColor(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>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<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">
|
||||
<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 className="grid-2">
|
||||
<div className="form-group">
|
||||
<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 className="form-group">
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue