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;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [showPhoneModal, setShowPhoneModal] = useState(false);
|
||||||
|
|
||||||
const handleBook = async () => {
|
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];
|
const [startHour, startMin] = selectedTime ? selectedTime.split(':').map(Number) : [9, 0];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -432,14 +444,57 @@ export default function PublicBookingPage({ params }: { params: Promise<{ slug:
|
||||||
Próximo <ChevronRight size={16} />
|
Próximo <ChevronRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button className="btn btn-primary" disabled={!canNext()} onClick={handleBook}
|
<button className="btn btn-primary" onClick={handleBook}
|
||||||
style={{ opacity: canNext() ? 1 : 0.5, padding: '12px 32px', fontSize: '16px' }}>
|
style={{ padding: '12px 32px', fontSize: '16px' }}>
|
||||||
<Check size={18} /> Confirmar Agendamento
|
<Check size={18} /> Confirmar Agendamento
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
||||||
<footer style={{ textAlign: 'center', padding: '32px 20px', borderTop: '1px solid var(--border-color)', marginTop: '40px' }}>
|
<footer style={{ textAlign: 'center', padding: '32px 20px', borderTop: '1px solid var(--border-color)', marginTop: '40px' }}>
|
||||||
<p style={{ fontSize: '13px', color: 'var(--text-muted)' }}>
|
<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
|
// Insert into notifications
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO notifications (tenant_id, type, title, message)
|
`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 tenantId = tenantRes.rows[0].id;
|
||||||
|
|
||||||
const res = await query(`
|
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,
|
TO_CHAR(birth_date, 'YYYY-MM-DD') as birthDate,
|
||||||
notes, total_visits as visits, total_spent as spent,
|
notes, total_visits as visits, total_spent as spent,
|
||||||
TO_CHAR(last_visit_at, 'YYYY-MM-DD') as "lastVisit"
|
TO_CHAR(last_visit_at, 'YYYY-MM-DD') as "lastVisit"
|
||||||
FROM clients
|
FROM clients
|
||||||
WHERE tenant_id = $1 AND is_active = true
|
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
|
ORDER BY name ASC
|
||||||
`, [tenantId]);
|
`, [tenantId]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ export async function GET(req: NextRequest) {
|
||||||
notifyClientReminder: settings.notify_client_reminder !== false,
|
notifyClientReminder: settings.notify_client_reminder !== false,
|
||||||
dailySummary: settings.daily_summary || false,
|
dailySummary: settings.daily_summary || false,
|
||||||
weeklyReport: settings.weekly_report !== false,
|
weeklyReport: settings.weekly_report !== false,
|
||||||
|
dailySchedules: settings.daily_schedules || null,
|
||||||
|
profSchedules: settings.prof_schedules || {},
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('GET Settings error:', error);
|
console.error('GET Settings error:', error);
|
||||||
|
|
@ -98,6 +100,8 @@ export async function PUT(req: NextRequest) {
|
||||||
notify_client_reminder: body.notifyClientReminder !== false,
|
notify_client_reminder: body.notifyClientReminder !== false,
|
||||||
daily_summary: body.dailySummary || false,
|
daily_summary: body.dailySummary || false,
|
||||||
weekly_report: body.weeklyReport !== false,
|
weekly_report: body.weeklyReport !== false,
|
||||||
|
daily_schedules: body.dailySchedules || null,
|
||||||
|
prof_schedules: body.profSchedules || {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await query(`
|
const res = await query(`
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,20 @@ export default function Settings() {
|
||||||
// Appearance
|
// Appearance
|
||||||
const [themeColor, setThemeColor] = useState('#6C63FF');
|
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
|
// Security
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
|
@ -112,6 +126,20 @@ export default function Settings() {
|
||||||
const wd = data.workingDays || [1, 2, 3, 4, 5, 6];
|
const wd = data.workingDays || [1, 2, 3, 4, 5, 6];
|
||||||
setWorkingDays([0, 1, 2, 3, 4, 5, 6].map(d => wd.includes(d)));
|
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
|
// Aplica cor do tema
|
||||||
if (data.themeColor) {
|
if (data.themeColor) {
|
||||||
document.documentElement.style.setProperty('--accent', data.themeColor);
|
document.documentElement.style.setProperty('--accent', data.themeColor);
|
||||||
|
|
@ -162,6 +190,8 @@ export default function Settings() {
|
||||||
notifyClientReminder: notifyClient,
|
notifyClientReminder: notifyClient,
|
||||||
dailySummary,
|
dailySummary,
|
||||||
weeklyReport,
|
weeklyReport,
|
||||||
|
dailySchedules,
|
||||||
|
profSchedules,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -333,25 +363,67 @@ export default function Settings() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="card-title mb-4">Dias de Funcionamento</h3>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
|
||||||
{['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado'].map((day, i) => (
|
<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={{
|
<div key={day} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: '16px',
|
display: 'flex', alignItems: 'center', gap: '16px', flexWrap: 'wrap',
|
||||||
padding: '12px 0', borderBottom: i < 6 ? '1px solid var(--border-color)' : 'none'
|
padding: '14px 0', borderBottom: i < 6 ? '1px solid var(--border-color)' : 'none'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ width: '100px', fontWeight: 600, fontSize: '14px' }}>{day}</div>
|
<div style={{ width: '100px', fontWeight: 600, fontSize: '14px' }}>{day}</div>
|
||||||
<div className={`toggle ${workingDays[i] ? 'active' : ''}`}
|
<div className={`toggle ${item.active ? 'active' : ''}`}
|
||||||
onClick={() => setWorkingDays(prev => { const n = [...prev]; n[i] = !n[i]; return n; })} />
|
onClick={() => updateSched(i, 'active', !item.active)} />
|
||||||
{workingDays[i] ? (
|
|
||||||
<span style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>Aberto para agendamentos online</span>
|
{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>
|
||||||
))}
|
);
|
||||||
<div style={{ marginTop: '16px' }}>
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '20px' }}>
|
||||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue