feat: popup alerta de telefone obrigatorio, sincronismo automatico de clientes online/app e horarios de atendimento por dia/profissional
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m40s Details

This commit is contained in:
Sidney 2026-07-19 22:45:17 -03:00
parent e7b3aee448
commit 8f933b3ba5
5 changed files with 193 additions and 20 deletions

View File

@ -127,7 +127,19 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
return false;
};
const [showPhoneModal, setShowPhoneModal] = useState(false);
const handleBook = async () => {
if (!clientName.trim()) {
showToast('Por favor, informe o seu nome completo.', 'warning');
return;
}
if (!clientPhone.trim()) {
showToast('É necessário informar o seu telefone para confirmar o agendamento!', 'error');
setShowPhoneModal(true);
return;
}
const [startHour, startMin] = selectedTime ? selectedTime.split(':').map(Number) : [9, 0];
try {
@ -432,14 +444,57 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
Próximo <ChevronRight size={16} />
</button>
) : (
<button className="btn btn-primary" disabled={!canNext()} onClick={handleBook}
style={{ opacity: canNext() ? 1 : 0.5, padding: '12px 32px', fontSize: '16px' }}>
<button className="btn btn-primary" onClick={handleBook}
style={{ padding: '12px 32px', fontSize: '16px' }}>
<Check size={18} /> Confirmar Agendamento
</button>
)}
</div>
</div>
{/* MODAL POP-UP TELEFONE OBRIGATÓRIO */}
{showPhoneModal && (
<div className="modal-overlay" onClick={() => setShowPhoneModal(false)}>
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: '420px', textAlign: 'center' }}>
<div className="modal-header" style={{ justifyContent: 'center', borderBottom: 'none', paddingBottom: 0 }}>
<div style={{ width: 60, height: 60, borderRadius: '50%', background: 'rgba(245, 158, 11, 0.15)', color: 'var(--warning)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto' }}>
<Phone size={30} />
</div>
</div>
<div className="modal-body">
<h3 style={{ fontSize: '20px', fontWeight: 800, marginBottom: '12px' }}>Telefone / WhatsApp Obrigatório 📱</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginBottom: '20px', lineHeight: 1.5 }}>
Por favor, informe o seu número de telefone ou WhatsApp para confirmarmos e enviarmos a nota do seu agendamento.
</p>
<div className="form-group" style={{ textAlign: 'left', marginBottom: '20px' }}>
<label className="form-label">Seu Telefone / WhatsApp *</label>
<input
className="form-input"
placeholder="(00) 00000-0000"
value={clientPhone}
autoFocus
onChange={e => setClientPhone(e.target.value)}
/>
</div>
<button
className="btn btn-primary"
style={{ width: '100%', padding: '12px', fontSize: '15px' }}
onClick={() => {
if (!clientPhone.trim()) {
showToast('Por favor, digite seu número de telefone.', 'warning');
return;
}
setShowPhoneModal(false);
handleBook();
}}
>
<Check size={16} /> Confirmar e Agendar
</button>
</div>
</div>
</div>
)}
{/* FOOTER */}
<footer style={{ textAlign: 'center', padding: '32px 20px', borderTop: '1px solid var(--border-color)', marginTop: '40px' }}>
<p style={{ fontSize: '13px', color: 'var(--text-muted)' }}>

View File

@ -118,6 +118,29 @@ export async function POST(req: NextRequest) {
]
);
// Sync client to `clients` table so it shows up in "Clientes" tab
if (client) {
try {
const existing = await query(
`SELECT id, total_visits, total_spent FROM clients WHERE tenant_id = $1 AND (name = $2 OR (phone = $3 AND phone IS NOT NULL))`,
[tenantId, client, phone || '']
);
if (existing.rows.length > 0) {
await query(
`UPDATE clients SET total_visits = COALESCE(total_visits, 0) + 1, total_spent = COALESCE(total_spent, 0) + $1, last_visit_at = NOW() WHERE id = $2`,
[Number(price || 0), existing.rows[0].id]
);
} else {
await query(
`INSERT INTO clients (tenant_id, name, phone, email, total_visits, total_spent, last_visit_at) VALUES ($1, $2, $3, $4, 1, $5, NOW())`,
[tenantId, client, phone || null, email || null, Number(price || 0)]
);
}
} catch (err) {
console.error('Client sync error:', err);
}
}
// Insert into notifications
await query(
`INSERT INTO notifications (tenant_id, type, title, message)

View File

@ -15,12 +15,31 @@ export async function GET(req: NextRequest) {
const tenantId = tenantRes.rows[0].id;
const res = await query(`
SELECT id, name, email, phone, cpf,
SELECT id::text, name, email, phone, cpf,
TO_CHAR(birth_date, 'YYYY-MM-DD') as birthDate,
notes, total_visits as visits, total_spent as spent,
TO_CHAR(last_visit_at, 'YYYY-MM-DD') as "lastVisit"
FROM clients
WHERE tenant_id = $1 AND is_active = true
UNION ALL
SELECT
('apt-' || MD5(client_name || COALESCE(client_phone, ''))) as id,
client_name as name,
COALESCE(client_email, '') as email,
COALESCE(client_phone, '') as phone,
'' as cpf,
'' as birthDate,
'Cliente Agendamento Online' as notes,
COUNT(*)::int as visits,
COALESCE(SUM(price), 0)::numeric as spent,
TO_CHAR(MAX(date), 'YYYY-MM-DD') as "lastVisit"
FROM appointments
WHERE tenant_id = $1 AND client_name IS NOT NULL AND client_name != ''
AND client_name NOT IN (SELECT name FROM clients WHERE tenant_id = $1 AND is_active = true)
GROUP BY client_name, client_phone, client_email
ORDER BY name ASC
`, [tenantId]);

View File

@ -64,6 +64,8 @@ export async function GET(req: NextRequest) {
notifyClientReminder: settings.notify_client_reminder !== false,
dailySummary: settings.daily_summary || false,
weeklyReport: settings.weekly_report !== false,
dailySchedules: settings.daily_schedules || null,
profSchedules: settings.prof_schedules || {},
});
} catch (error: any) {
console.error('GET Settings error:', error);
@ -98,6 +100,8 @@ export async function PUT(req: NextRequest) {
notify_client_reminder: body.notifyClientReminder !== false,
daily_summary: body.dailySummary || false,
weekly_report: body.weeklyReport !== false,
daily_schedules: body.dailySchedules || null,
prof_schedules: body.profSchedules || {},
};
const res = await query(`

View File

@ -64,6 +64,20 @@ export default function Settings() {
// Appearance
const [themeColor, setThemeColor] = useState('#6C63FF');
// Custom Schedules per day & per professional
const [professionals, setProfessionals] = useState<any[]>([]);
const [selectedProfId, setSelectedProfId] = useState<string>('all');
const [dailySchedules, setDailySchedules] = useState<{ active: boolean; start: string; end: string }[]>([
{ active: false, start: '09:00', end: '18:00' }, // Dom
{ active: true, start: '09:00', end: '19:00' }, // Seg
{ active: true, start: '09:00', end: '19:00' }, // Ter
{ active: true, start: '09:00', end: '19:00' }, // Qua
{ active: true, start: '09:00', end: '19:00' }, // Qui
{ active: true, start: '09:00', end: '19:00' }, // Sex
{ active: true, start: '09:00', end: '17:00' }, // Sáb
]);
const [profSchedules, setProfSchedules] = useState<Record<string, { active: boolean; start: string; end: string }[]>>({});
// Security
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
@ -112,6 +126,20 @@ export default function Settings() {
const wd = data.workingDays || [1, 2, 3, 4, 5, 6];
setWorkingDays([0, 1, 2, 3, 4, 5, 6].map(d => wd.includes(d)));
if (data.dailySchedules) {
setDailySchedules(data.dailySchedules);
}
if (data.profSchedules) {
setProfSchedules(data.profSchedules);
}
// Carrega profissionais para horario individual
const teamRes = await fetch(`/api/tenant/team?slug=${activeSlug}`);
if (teamRes.ok) {
const teamData = await teamRes.json();
setProfessionals(teamData || []);
}
// Aplica cor do tema
if (data.themeColor) {
document.documentElement.style.setProperty('--accent', data.themeColor);
@ -162,6 +190,8 @@ export default function Settings() {
notifyClientReminder: notifyClient,
dailySummary,
weeklyReport,
dailySchedules,
profSchedules,
})
});
@ -333,25 +363,67 @@ export default function Settings() {
</div>
<div className="card">
<h3 className="card-title mb-4">Dias de Funcionamento</h3>
{['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado'].map((day, i) => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
<div>
<h3 className="card-title">Dias e Horários Personalizados</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>Configure os horários de início e fim do expediente para cada dia da semana e profissional.</p>
</div>
<div style={{ minWidth: '220px' }}>
<label className="form-label" style={{ fontSize: '12px', marginBottom: '4px' }}>Horário do Profissional:</label>
<select className="form-select" value={selectedProfId} onChange={e => setSelectedProfId(e.target.value)}>
<option value="all">🏢 Geral (Estabelecimento)</option>
{professionals.map(p => (
<option key={p.id} value={p.id}>👤 {p.name}</option>
))}
</select>
</div>
</div>
{(() => {
const activeSched = selectedProfId === 'all'
? dailySchedules
: (profSchedules[selectedProfId] || dailySchedules);
const updateSched = (index: number, field: 'active' | 'start' | 'end', val: any) => {
const updated = [...activeSched];
updated[index] = { ...updated[index], [field]: val };
if (selectedProfId === 'all') {
setDailySchedules(updated);
} else {
setProfSchedules({ ...profSchedules, [selectedProfId]: updated });
}
};
return ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado'].map((day, i) => {
const item = activeSched[i] || { active: i > 0, start: '09:00', end: '19:00' };
return (
<div key={day} style={{
display: 'flex', alignItems: 'center', gap: '16px',
padding: '12px 0', borderBottom: i < 6 ? '1px solid var(--border-color)' : 'none'
display: 'flex', alignItems: 'center', gap: '16px', flexWrap: 'wrap',
padding: '14px 0', borderBottom: i < 6 ? '1px solid var(--border-color)' : 'none'
}}>
<div style={{ width: '100px', fontWeight: 600, fontSize: '14px' }}>{day}</div>
<div className={`toggle ${workingDays[i] ? 'active' : ''}`}
onClick={() => setWorkingDays(prev => { const n = [...prev]; n[i] = !n[i]; return n; })} />
{workingDays[i] ? (
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>Aberto para agendamentos online</span>
<div className={`toggle ${item.active ? 'active' : ''}`}
onClick={() => updateSched(i, 'active', !item.active)} />
{item.active ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input type="time" className="form-input" style={{ width: '110px', padding: '6px 10px' }}
value={item.start} onChange={e => updateSched(i, 'start', e.target.value)} />
<span style={{ color: 'var(--text-muted)' }}>até</span>
<input type="time" className="form-input" style={{ width: '110px', padding: '6px 10px' }}
value={item.end} onChange={e => updateSched(i, 'end', e.target.value)} />
</div>
) : (
<span style={{ color: 'var(--text-muted)', fontSize: '13px' }}>Fechado</span>
<span style={{ color: 'var(--text-muted)', fontSize: '13px' }}>Fechado neste dia</span>
)}
</div>
))}
<div style={{ marginTop: '16px' }}>
);
});
})()}
<div style={{ marginTop: '20px' }}>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
<Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar'}
{saving ? <><Loader size={16} className="spin" /> Salvando...</> : <><Save size={16} /> {saved ? '✓ Salvo!' : 'Salvar Alterações'}</>}
</button>
</div>
</div>