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
Build and Deploy (Gitea Actions) / build-and-deploy (push) Successful in 2m40s
Details
This commit is contained in:
parent
e7b3aee448
commit
8f933b3ba5
|
|
@ -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)' }}>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue