feat: conectar Equipe e Agenda ao banco de dados PostgreSQL
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m41s Details

This commit is contained in:
Sidney 2026-06-07 19:12:15 -03:00
parent 04a556290e
commit c4871262f9
4 changed files with 320 additions and 98 deletions

View File

@ -0,0 +1,84 @@
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({ professionals: [], appointments: [] });
const tenantId = tenantRes.rows[0].id;
const profsRes = await query(`SELECT id, name FROM users WHERE tenant_id = $1 AND role = 'professional'`, [tenantId]);
// In PostgreSQL, DATE columns return as Date objects or strings depending on driver. Let's cast to text.
const apptsRes = await query(`
SELECT
a.id, a.professional_id as "professionalId",
COALESCE(c.name, a.client_name, 'Cliente Avulso') as client,
COALESCE(s.name, 'Serviço Personalizado') as service,
EXTRACT(HOUR FROM a.start_time) as "startHour",
EXTRACT(MINUTE FROM a.start_time) as "startMin",
EXTRACT(EPOCH FROM (a.end_time - a.start_time))/60 as duration,
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
WHERE a.tenant_id = $1 AND a.date = CURRENT_DATE
`, [tenantId]);
const colors = ['#6C63FF', '#a855f7', '#10b981', '#f59e0b', '#ef4444'];
const professionals = profsRes.rows.map((p, i) => ({
id: p.id,
name: p.name,
color: colors[i % colors.length]
}));
const appointments = apptsRes.rows.map(a => ({
...a,
startHour: Number(a.startHour),
startMin: Number(a.startMin),
duration: Number(a.duration),
price: Number(a.price)
}));
return NextResponse.json({ professionals, appointments });
} catch (error: any) {
console.error('GET Agenda error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const { slug, professionalId, client, service, startHour, startMin, duration, price } = await req.json();
if (!slug || !professionalId) return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
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;
const startTime = `${String(startHour).padStart(2, '0')}:${String(startMin).padStart(2, '0')}:00`;
// Calculate end time
const dateObj = new Date(`1970-01-01T${startTime}Z`);
dateObj.setMinutes(dateObj.getMinutes() + Number(duration));
const endTime = dateObj.toISOString().substr(11, 8);
const res = await query(
`INSERT INTO appointments (tenant_id, professional_id, client_name, notes, date, start_time, end_time, price, status)
VALUES ($1, $2, $3, $4, CURRENT_DATE, $5, $6, $7, 'confirmed') RETURNING *`,
[tenantId, professionalId, client, service, startTime, endTime, price]
);
return NextResponse.json(res.rows[0]);
} catch (error: any) {
console.error('POST Agenda error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import crypto from 'crypto';
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([]);
const tenantId = tenantRes.rows[0].id;
const profsRes = await query(`
SELECT
u.id, u.name, u.role, u.phone, u.email,
(SELECT COALESCE(AVG(commission_percent), 0) FROM professional_services WHERE professional_id = u.id) as commission,
COALESCE(
(SELECT json_agg(s.name) FROM professional_services ps JOIN services s ON ps.service_id = s.id WHERE ps.professional_id = u.id),
'[]'::json
) as services,
(SELECT COUNT(*) FROM appointments WHERE professional_id = u.id AND status = 'completed') as total_clients
FROM users u
WHERE u.tenant_id = $1 AND u.role = 'professional' AND u.is_active = true
ORDER BY u.created_at DESC
`, [tenantId]);
const professionals = profsRes.rows.map(row => ({
id: row.id,
name: row.name,
role: row.role || 'Profissional',
phone: row.phone || '',
email: row.email || '',
commission: parseInt(row.commission || 0),
services: row.services || [],
schedule: { seg: '09:00-18:00', ter: '09:00-18:00', qua: '09:00-18:00', qui: '09:00-18:00', sex: '09:00-18:00', sab: '09:00-18:00' },
rating: 5.0,
totalClients: parseInt(row.total_clients || 0)
}));
return NextResponse.json(professionals);
} catch (error: any) {
console.error('GET Team error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const { slug, name, email, phone, role, commission } = await req.json();
if (!slug || !name) return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
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;
// Use a random hash for password since they are created by admin
const passwordHash = crypto.randomBytes(16).toString('hex');
const safeEmail = email || `${Date.now()}@agendapro.com`;
const res = await query(
`INSERT INTO users (tenant_id, name, email, phone, role, password_hash)
VALUES ($1, $2, $3, $4, 'professional', $5) RETURNING *`,
[tenantId, name, safeEmail, phone || null, passwordHash]
);
return NextResponse.json(res.rows[0]);
} catch (error: any) {
console.error('POST Team error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function PUT(req: NextRequest) {
try {
const { id, name, email, phone, role } = await req.json();
if (!id || !name) return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
const res = await query(
`UPDATE users SET name = $1, email = $2, phone = $3, updated_at = NOW() WHERE id = $4 AND role = 'professional' RETURNING *`,
[name, email, phone, id]
);
return NextResponse.json(res.rows[0]);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function DELETE(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get('id');
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
// Hard delete for now, or soft delete `is_active = false`. Let's do hard delete to clean up immediately.
await query('DELETE FROM users WHERE id = $1', [id]);
return NextResponse.json({ success: true });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@ -1,19 +1,6 @@
import { useState, useEffect } from 'react';
import { Plus, ChevronLeft, ChevronRight, X, Clock, User, Calendar } from 'lucide-react';
const professionals = [
{ id: '1', name: 'Carlos Silva', color: '#6C63FF' },
{ id: '2', name: 'Rafael Santos', color: '#a855f7' },
];
const mockAppointments = [
{ id: '1', professionalId: '1', client: 'João Mendes', service: 'Corte Masculino', startHour: 10, startMin: 0, duration: 40, status: 'confirmed', price: 55 },
{ id: '2', professionalId: '1', client: 'Pedro Oliveira', service: 'Combo Corte + Barba', startHour: 11, startMin: 0, duration: 60, status: 'confirmed', price: 85 },
{ id: '3', professionalId: '2', client: 'Lucas Ferreira', service: 'Barba Completa', startHour: 14, startMin: 0, duration: 30, status: 'pending', price: 40 },
{ id: '4', professionalId: '2', client: 'Gabriel Souza', service: 'Corte Masculino', startHour: 10, startMin: 0, duration: 40, status: 'completed', price: 55 },
{ id: '5', professionalId: '1', client: 'Mateus Costa', service: 'Design de Sobrancelha', startHour: 15, startMin: 30, duration: 15, status: 'confirmed', price: 25 },
];
const hours = Array.from({ length: 12 }, (_, i) => i + 8);
const statusColors: Record<string, string> = {
@ -33,31 +20,43 @@ export default function Agenda() {
const [showModal, setShowModal] = useState(false);
const today = new Date();
const [currentDate, setCurrentDate] = useState(today);
const [appointments, setAppointments] = useState(mockAppointments);
const [appointments, setAppointments] = useState<any[]>([]);
const [professionals, setProfessionals] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const fetchAgenda = async () => {
try {
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
const res = await fetch(`/api/tenant/agenda?slug=${slug}`);
if (res.ok) {
const data = await res.json();
setProfessionals(data.professionals || []);
setAppointments(data.appointments || []);
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
const activeSlug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
const savedAppts = localStorage.getItem(`agendapro_appointments_${activeSlug}`);
if (savedAppts) {
setAppointments(JSON.parse(savedAppts));
} else {
const savedName = localStorage.getItem('agendapro_tenant_name');
if (savedName) {
setAppointments([]);
localStorage.setItem(`agendapro_appointments_${activeSlug}`, JSON.stringify([]));
} else {
setAppointments(mockAppointments);
}
}
}, []);
fetchAgenda();
}, [currentDate]);
// States do Modal de Novo Agendamento
const [newClient, setNewClient] = useState('');
const [newPhone, setNewPhone] = useState('');
const [newService, setNewService] = useState('Corte Masculino - R$ 55,00 (40min)');
const [newProf, setNewProf] = useState('1');
const [newProf, setNewProf] = useState('');
const [newTime, setNewTime] = useState('10:00');
useEffect(() => {
if (professionals.length > 0 && !newProf) {
setNewProf(professionals[0].id);
}
}, [professionals]);
const dateStr = currentDate.toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const changeDate = (days: number) => {
@ -70,6 +69,10 @@ export default function Agenda() {
setCurrentDate(new Date());
};
if (loading && professionals.length === 0) {
return <div className="p-8 text-center" style={{color: 'var(--text-muted)'}}>Carregando agenda...</div>;
}
return (
<div className="slide-up">
<div className="flex-between mb-6">
@ -86,8 +89,14 @@ export default function Agenda() {
</div>
</div>
{professionals.length === 0 ? (
<div className="empty-state">
<h3>Sua agenda está vazia</h3>
<p>Você precisa adicionar um profissional na aba Equipe antes de agendar clientes.</p>
</div>
) : (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: '70px repeat(2, 1fr)', borderBottom: '1px solid var(--border-color)' }}>
<div style={{ display: 'grid', gridTemplateColumns: `70px repeat(${professionals.length}, 1fr)`, borderBottom: '1px solid var(--border-color)' }}>
<div style={{ padding: '14px', borderRight: '1px solid var(--border-color)' }} />
{professionals.map(p => (
<div key={p.id} style={{
@ -102,7 +111,7 @@ export default function Agenda() {
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '70px repeat(2, 1fr)', position: 'relative' }}>
<div style={{ display: 'grid', gridTemplateColumns: `70px repeat(${professionals.length}, 1fr)`, position: 'relative' }}>
<div>
{hours.map(h => (
<div key={h} style={{
@ -116,14 +125,7 @@ export default function Agenda() {
</div>
{professionals.map(prof => {
// Lógica simples para simular agendamentos diferentes em dias diferentes
const daySeed = currentDate.getDate();
const isToday = daySeed === new Date().getDate();
// Se for hoje, mostra tudo que for do profissional. Se não, filtra baseado no dia para parecer que mudou.
const visibleAppointments = appointments.filter(a =>
a.professionalId === prof.id && (isToday || (parseInt(a.id) + daySeed) % 2 === 0)
);
const visibleAppointments = appointments.filter(a => a.professionalId === prof.id);
return (
<div key={prof.id} style={{ position: 'relative', borderRight: '1px solid var(--border-color)' }}>
@ -140,8 +142,8 @@ export default function Agenda() {
<div key={apt.id} style={{
position: 'absolute', top: `${top}px`, left: '4px', right: '4px',
height: `${height}px`, borderRadius: 'var(--radius-sm)',
background: statusColors[apt.status],
borderLeft: `3px solid ${statusBorders[apt.status]}`,
background: statusColors[apt.status] || statusColors.confirmed,
borderLeft: `3px solid ${statusBorders[apt.status] || statusBorders.confirmed}`,
padding: isSmall ? '4px 8px' : '6px 10px', cursor: 'pointer', overflow: 'hidden',
transition: 'all 0.2s', zIndex: 2, display: 'flex', flexDirection: isSmall ? 'row' : 'column',
alignItems: isSmall ? 'center' : 'flex-start', gap: isSmall ? '8px' : '0'
@ -168,6 +170,7 @@ export default function Agenda() {
})}
</div>
</div>
)}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
@ -204,14 +207,15 @@ export default function Agenda() {
<div className="form-group">
<label className="form-label">Profissional</label>
<select className="form-select" value={newProf} onChange={e => setNewProf(e.target.value)}>
<option value="1">Carlos Silva</option>
<option value="2">Rafael Santos</option>
{professionals.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div className="grid-2">
<div className="form-group">
<label className="form-label"><Calendar size={14} style={{ display: 'inline', marginRight: 4 }} />Data</label>
<input className="form-input" type="date" defaultValue={today.toISOString().split('T')[0]} />
<input className="form-input" type="date" defaultValue={today.toISOString().split('T')[0]} readOnly />
</div>
<div className="form-group">
<label className="form-label"><Clock size={14} style={{ display: 'inline', marginRight: 4 }} />Horário</label>
@ -225,7 +229,7 @@ export default function Agenda() {
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Cancelar</button>
<button className="btn btn-primary" onClick={() => {
<button className="btn btn-primary" onClick={async () => {
const [h, m] = newTime.split(':').map(Number);
const serviceName = newService.split(' - ')[0];
const priceMatch = newService.match(/R\$ (\d+)/);
@ -233,29 +237,32 @@ export default function Agenda() {
const durMatch = newService.match(/\((\d+)min\)/);
const duration = durMatch ? parseInt(durMatch[1]) : 30;
const newApt = {
id: String(Date.now()),
professionalId: newProf,
client: newClient || 'Cliente Avulso',
service: serviceName,
startHour: h,
startMin: m,
duration: duration,
status: 'confirmed',
price: price
};
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
// Volta pra hoje pra ver o agendamento cair no grid
const activeSlug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
const updated = [...appointments, newApt];
setCurrentDate(new Date());
setAppointments(updated);
localStorage.setItem(`agendapro_appointments_${activeSlug}`, JSON.stringify(updated));
setShowModal(false);
// Reseta form
setNewClient('');
setNewPhone('');
try {
const res = await fetch('/api/tenant/agenda', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug,
professionalId: newProf,
client: newClient || 'Cliente Avulso',
service: serviceName,
startHour: h,
startMin: m,
duration,
price
})
});
if (res.ok) {
fetchAgenda();
setShowModal(false);
setNewClient('');
setNewPhone('');
}
} catch (e) {
console.error(e);
}
}}>Agendar</button>
</div>
</div>

View File

@ -1,5 +1,5 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Plus, X, Clock, Edit, Trash2, Star, AlertTriangle } from 'lucide-react';
interface Professional {
@ -7,21 +7,6 @@ interface Professional {
services: string[]; schedule: Record<string, string>; rating: number; totalClients: number;
}
const initialTeam: Professional[] = [
{
id: '1', name: 'Carlos Silva', role: 'Barbeiro Senior', phone: '(11) 98888-1111',
email: 'carlos@demo.com', commission: 40, services: ['Corte Masculino', 'Barba Completa', 'Combo Corte + Barba', 'Corte Infantil', 'Design de Sobrancelha'],
schedule: { seg: '09:00-19:00', ter: '09:00-19:00', qua: '09:00-19:00', qui: '09:00-19:00', sex: '09:00-19:00', sab: '09:00-19:00' },
rating: 4.8, totalClients: 89
},
{
id: '2', name: 'Rafael Santos', role: 'Barbeiro', phone: '(11) 98888-2222',
email: 'rafael@demo.com', commission: 35, services: ['Corte Masculino', 'Barba Completa', 'Combo Corte + Barba'],
schedule: { seg: '10:00-20:00', ter: '10:00-20:00', qua: '10:00-20:00', qui: '10:00-20:00', sex: '10:00-20:00', sab: '10:00-20:00' },
rating: 4.6, totalClients: 65
},
];
const emptyProfessional: Professional = {
id: '', name: '', role: '', phone: '', email: '', commission: 30,
services: [], schedule: { seg: '09:00-18:00', ter: '09:00-18:00', qua: '09:00-18:00', qui: '09:00-18:00', sex: '09:00-18:00', sab: '09:00-18:00' },
@ -29,15 +14,35 @@ const emptyProfessional: Professional = {
};
export default function Team() {
const [team, setTeam] = useState<Professional[]>(initialTeam);
const [team, setTeam] = useState<Professional[]>([]);
const [showModal, setShowModal] = useState(false);
const [editingProf, setEditingProf] = useState<Professional | null>(null);
const [formData, setFormData] = useState<Professional>(emptyProfessional);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const fetchTeam = async () => {
try {
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
const res = await fetch(`/api/tenant/team?slug=${slug}`);
if (res.ok) {
const data = await res.json();
setTeam(data);
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTeam();
}, []);
const openAdd = () => {
setEditingProf(null);
setFormData({ ...emptyProfessional, id: Date.now().toString() });
setFormData({ ...emptyProfessional });
setShowModal(true);
};
@ -47,22 +52,41 @@ export default function Team() {
setShowModal(true);
};
const handleSave = () => {
const handleSave = async () => {
if (!formData.name.trim()) return;
if (editingProf) {
setTeam(prev => prev.map(p => p.id === editingProf.id ? { ...formData } : p));
} else {
setTeam(prev => [...prev, { ...formData }]);
const slug = localStorage.getItem('agendapro_tenant_slug') || 'barbearia-premium';
const method = editingProf ? 'PUT' : 'POST';
const payload = { ...formData, slug };
try {
const res = await fetch('/api/tenant/team', {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
fetchTeam();
setShowModal(false);
setEditingProf(null);
}
} catch (e) {
console.error(e);
}
setShowModal(false);
setEditingProf(null);
};
const handleDelete = (id: string) => {
setTeam(prev => prev.filter(p => p.id !== id));
setDeleteConfirm(null);
const handleDelete = async (id: string) => {
try {
await fetch(`/api/tenant/team?id=${id}`, { method: 'DELETE' });
setTeam(prev => prev.filter(p => p.id !== id));
setDeleteConfirm(null);
} catch (e) {
console.error(e);
}
};
if (loading) return <div className="p-8 text-center" style={{color: 'var(--text-muted)'}}>Carregando equipe...</div>;
return (
<div className="slide-up">
<div className="flex-between mb-6">
@ -82,7 +106,7 @@ export default function Team() {
}}>
<AlertTriangle size={36} color="var(--warning)" />
<p style={{ fontSize: '15px', fontWeight: 600, textAlign: 'center' }}>Excluir {p.name}?</p>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', textAlign: 'center' }}>Essa ação não pode ser desfeita.</p>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', textAlign: 'center' }}>Essa ação removerá o profissional de toda a agenda.</p>
<div style={{ display: 'flex', gap: '10px' }}>
<button className="btn btn-danger" onClick={() => handleDelete(p.id)}>Sim, excluir</button>
<button className="btn btn-secondary" onClick={() => setDeleteConfirm(null)}>Cancelar</button>
@ -138,10 +162,10 @@ export default function Team() {
<Clock size={12} style={{ display: 'inline', marginRight: '4px' }} /> Horário de Trabalho
</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '6px' }}>
{Object.entries(p.schedule).map(([day, hours]) => (
{Object.entries(p.schedule || {}).map(([day, hours]) => (
<div key={day} style={{ fontSize: '12px', padding: '4px 8px', background: 'var(--bg-primary)', borderRadius: '4px', textAlign: 'center' }}>
<span style={{ color: 'var(--text-muted)', textTransform: 'capitalize' }}>{day} </span>
<span style={{ fontWeight: 600 }}>{hours}</span>
<span style={{ fontWeight: 600 }}>{hours as string}</span>
</div>
))}
</div>